Files
HoneyOS/internal/store/store.go
T
marc 3807ddca24 Notiz zeigt sich sofort gesetzt, Widgets nach Wunsch des Nutzers
Aus dem Zettel wird die Notiz: Der Text steht immer formatiert da, roh ist nur
die Zeile mit der Schreibmarke. Dazu eine kleine Werkzeugleiste (Aufgabe,
Aufzaehlung, Nummer, Ueberschrift, fett, Linie), Enter fuehrt Listen fort,
Backspace am Zeilenanfang nimmt erst den Vorsatz. Fuer groessere Umbauten
bleibt der Quelltext-Knopf.

Kein contenteditable: Der Quelltext bleibt die Wahrheit und wird nie aus dem
HTML zurueckgerechnet, das der Browser beim Tippen erzeugt.

Reihenfolge und An/Aus der Widgets liegen jetzt je Nutzer auf dem Server, in
einer Einstellung ("benutzer,notiz,-system,wetter,bild"). Der Schalter fuer das
Wetter auf dem Schreibtisch faellt damit weg — es ist ein Widget wie jedes
andere.

Die Einstellungen sind in Abschnitte sortiert (Darstellung, HoneyBar, Widgets,
Wetter, Programme, Konto), Beschriftung links, Schalter rechts.
2026-08-31 01:24:50 +02:00

385 lines
11 KiB
Go

// Package store hält den ganzen Zustand von HoneyOS: die App-Liste und je
// Nutzer ein paar Einstellungen.
package store
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
// Der ganze Zustand von HoneyOS passt in eine JSON-Datei: eine Handvoll Apps
// und pro Nutzer ein paar kosmetische Einstellungen. Eine Datenbank wäre hier
// mehr Betrieb als Nutzen — die Datei lässt sich lesen, sichern und im Zweifel
// mit einem Editor reparieren. Geschrieben wird atomar (tmp + rename), damit
// ein Absturz mitten im Schreiben keine halbe Datei hinterlässt.
// LocalSub ist die ID des einen Nutzers im offenen Betrieb — dann meldet sich
// niemand an, und alles gehört ihm.
const LocalSub = "local"
type App struct {
ID string `json:"id"`
Name string `json:"name"`
Subtitle string `json:"subtitle,omitempty"`
URL string `json:"url"`
Glyph string `json:"glyph,omitempty"`
}
type User struct {
Name string `json:"name,omitempty"`
Picture string `json:"picture,omitempty"`
Settings map[string]string `json:"settings,omitempty"`
// Die Notiz. Nicht in den Einstellungen, obwohl sie dorthin passen würde:
// Die sind auf kurze Werte ausgelegt (200 Zeichen je Feld), und eine Notiz
// ist Text, kein Schalter.
Note string `json:"note,omitempty"`
// Das Bild — entweder eine Datei neben honeyos.json oder eine fremde
// Adresse, nie beides. `Bildstand` ist der Versionsanteil in der Adresse:
// Ohne ihn bliebe beim Austausch das alte Bild im Zwischenspeicher hängen.
Bild string `json:"bild,omitempty"` // Dateiname in bilder/
BildURL string `json:"bildUrl,omitempty"` // oder eine fremde Adresse
Bildstand string `json:"bildstand,omitempty"` // gegen den Zwischenspeicher
}
type data struct {
Apps []App `json:"apps"`
Users map[string]*User `json:"users"`
}
type Store struct {
mu sync.RWMutex
path string
d data
}
const (
maxApps = 24
maxSettings = 40
maxFieldLen = 200
// Eine Notiz, kein Dokument. Wer mehr braucht, hat InkNote — und der Server
// muss die Grenze durchsetzen, nicht das Formular.
maxNoteLen = 4000
)
var (
idPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,31}$`)
knownGlyphs = map[string]bool{"cloud": true, "note": true, "box": true, "chip": true, "folder": true, "gear": true, "globe": true, "disk": true}
settingKeyRe = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]{0,31}$`)
)
func Open(path string) (*Store, error) {
s := &Store{path: path, d: data{Apps: []App{}, Users: map[string]*User{}}}
raw, err := os.ReadFile(path)
if os.IsNotExist(err) {
return s, s.save() // gleich anlegen, damit ein Rechteproblem sofort auffällt
}
if err != nil {
return nil, fmt.Errorf("lese %s: %w", path, err)
}
if err := json.Unmarshal(raw, &s.d); err != nil {
return nil, fmt.Errorf("%s ist kein gültiges JSON: %w", path, err)
}
if s.d.Apps == nil {
s.d.Apps = []App{}
}
if s.d.Users == nil {
s.d.Users = map[string]*User{}
}
return s, nil
}
// save schreibt die Datei. Aufrufer hält die Sperre.
func (s *Store) save() error {
raw, err := json.MarshalIndent(s.d, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, append(raw, '\n'), 0o600); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
func (s *Store) Apps() []App {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]App, len(s.d.Apps))
copy(out, s.d.Apps)
return out
}
// SetApps ersetzt die ganze Liste. Die Oberfläche schickt sie immer vollständig
// — für ein Dutzend Einträge ist das einfacher und weniger fehleranfällig als
// einzelne Endpunkte für Anlegen, Ändern, Löschen und Umsortieren.
func (s *Store) SetApps(apps []App) error {
if len(apps) > maxApps {
return fmt.Errorf("höchstens %d Apps", maxApps)
}
seen := map[string]bool{}
cleaned := make([]App, 0, len(apps))
for i, a := range apps {
a.ID = strings.ToLower(strings.TrimSpace(a.ID))
a.Name = strings.TrimSpace(a.Name)
a.Subtitle = strings.TrimSpace(a.Subtitle)
a.URL = strings.TrimSpace(a.URL)
a.Glyph = strings.TrimSpace(a.Glyph)
if !idPattern.MatchString(a.ID) {
return fmt.Errorf("App %d: ID muss klein geschrieben sein und mit Buchstabe oder Ziffer beginnen", i+1)
}
if seen[a.ID] {
return fmt.Errorf("ID %q kommt doppelt vor", a.ID)
}
seen[a.ID] = true
if a.Name == "" {
a.Name = strings.ToUpper(a.ID)
}
u, err := url.Parse(a.URL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return fmt.Errorf("App %q: Adresse muss mit http:// oder https:// beginnen", a.ID)
}
if a.Glyph != "" && !knownGlyphs[a.Glyph] {
a.Glyph = ""
}
for _, f := range []string{a.Name, a.Subtitle, a.URL} {
if len(f) > maxFieldLen {
return fmt.Errorf("App %q: ein Feld ist länger als %d Zeichen", a.ID, maxFieldLen)
}
}
cleaned = append(cleaned, a)
}
s.mu.Lock()
defer s.mu.Unlock()
s.d.Apps = cleaned
return s.save()
}
func (s *Store) User(sub string) User {
s.mu.RLock()
defer s.mu.RUnlock()
if u, ok := s.d.Users[sub]; ok {
cp := *u
cp.Settings = map[string]string{}
for k, v := range u.Settings {
cp.Settings[k] = v
}
return cp
}
return User{Settings: map[string]string{}}
}
// SeenUser hält Name und Bild aus dem Anbieter nach, ohne Einstellungen anzufassen.
func (s *Store) SeenUser(sub, name, picture string) error {
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.d.Users[sub]
if !ok {
u = &User{Settings: map[string]string{}}
s.d.Users[sub] = u
}
if u.Name == name && u.Picture == picture {
return nil // nichts zu schreiben
}
u.Name, u.Picture = name, picture
return s.save()
}
// Note gibt die Notiz des Nutzers. Kein Rückfall auf den Standardnutzer wie
// bei den Einstellungen: Eine fremde Notiz ist kein Ersatz für die eigene.
func (s *Store) Note(sub string) string {
return s.User(sub).Note
}
// SetNote schreibt die Notiz. Zwei offene Geräte überschreiben einander — der
// letzte Stand gewinnt. Für eine Notiz ist das vertretbar; alles darüber
// bräuchte Versionen und eine Zusammenführung, und dann wäre es keine
// Kleinigkeit mehr, sondern eine Notiz-App.
func (s *Store) SetNote(sub, text string) error {
if len([]rune(text)) > maxNoteLen {
return fmt.Errorf("die Notiz fasst %d Zeichen", maxNoteLen)
}
s.mu.Lock()
defer s.mu.Unlock()
s.userLocked(sub).Note = text
return s.save()
}
func (s *Store) SetSettings(sub string, in map[string]string) error {
if len(in) > maxSettings {
return fmt.Errorf("zu viele Einstellungen")
}
clean := map[string]string{}
for k, v := range in {
if !settingKeyRe.MatchString(k) || len(v) > maxFieldLen {
continue // stillschweigend verwerfen: die Oberfläche schickt nur Bekanntes
}
clean[k] = v
}
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.d.Users[sub]
if !ok {
u = &User{}
s.d.Users[sub] = u
}
u.Settings = clean
return s.save()
}
// SettingsFor liefert das Aussehen für sub — und hilft nach, wenn die Frage
// über eine Site-Grenze kam.
//
// Die HoneyBar in einer fremden App weist sich nur mit dem Schlüssel aus, nicht
// mit einer Sitzung: der Browser schickt das Cookie dorthin nicht mit. Sie
// landet deshalb beim Standardnutzer. Sind die Einstellungen aber unter einem
// Namen aus der Proxy-Kopfzeile gespeichert, wäre der leer — und die HoneyBar
// bliebe bernsteinfarben, während der Schreibtisch grün leuchtet.
//
// Gibt es genau einen Nutzer mit Einstellungen, gilt dessen Aussehen. Bei
// mehreren bleibt es beim Standard: dann ist nicht zu erraten, wer da schaut.
func (s *Store) SettingsFor(sub string) map[string]string {
if own := s.User(sub).Settings; len(own) > 0 {
return own
}
s.mu.RLock()
defer s.mu.RUnlock()
var einziger map[string]string
for _, u := range s.d.Users {
if len(u.Settings) == 0 {
continue
}
if einziger != nil {
return map[string]string{} // mehrere — kein Rateweg
}
einziger = u.Settings
}
out := map[string]string{}
for k, v := range einziger {
out[k] = v
}
return out
}
// AppOrigins nennt die Ursprünge der eingetragenen Apps. Nur sie dürfen die
// HoneyBar mit Anmeldedaten abfragen — die Liste ist damit ihre eigene Freigabe.
func (s *Store) AppOrigins() map[string]bool {
s.mu.RLock()
defer s.mu.RUnlock()
out := map[string]bool{}
for _, a := range s.d.Apps {
if u, err := url.Parse(a.URL); err == nil && u.Host != "" {
out[u.Scheme+"://"+u.Host] = true
}
}
return out
}
/* ── Bilder ────────────────────────────────────────────────────────────────
*
* Das Erste, was HoneyOS als Datei ablegt. Bis hierher war alles in einer
* einzigen JSON-Datei, und das soll auch so bleiben: Die Bilder liegen daneben
* in `bilder/`, die JSON kennt nur ihre Namen. Ein Nutzer hat genau **ein**
* Bild — ein neues ersetzt das alte, samt Datei.
*
* Der Dateiname ist ein Fingerabdruck der Nutzerkennung, keine Kennung im
* Klartext: In einem Ordnernamen hätte eine OIDC-`sub` nichts zu suchen, und
* manche taugen gar nicht als Dateiname.
*/
func (s *Store) bilderDir() string { return filepath.Join(filepath.Dir(s.path), "bilder") }
func bildName(sub, endung string) string {
sum := sha256.Sum256([]byte(sub))
return hex.EncodeToString(sum[:8]) + endung
}
// Bild sagt, was der Nutzer hinterlegt hat: eine Datei, eine Adresse oder
// nichts. `stand` gehört an die Adresse der Datei.
func (s *Store) Bild(sub string) (datei, url, stand string) {
u := s.User(sub)
return u.Bild, u.BildURL, u.Bildstand
}
func (s *Store) BildPfad(sub string) (string, bool) {
datei, _, _ := s.Bild(sub)
if datei == "" {
return "", false
}
return filepath.Join(s.bilderDir(), datei), true
}
// SetBildDatei legt die Bytes ab und macht sie zum Bild des Nutzers. Ein
// vorhandenes Bild wird ersetzt — auch die alte Datei, sonst wächst der Ordner
// mit jedem Austausch.
func (s *Store) SetBildDatei(sub, endung string, daten []byte) error {
if err := os.MkdirAll(s.bilderDir(), 0o755); err != nil {
return fmt.Errorf("bilder-ordner: %w", err)
}
name := bildName(sub, endung)
if err := os.WriteFile(filepath.Join(s.bilderDir(), name), daten, 0o644); err != nil {
return fmt.Errorf("bild schreiben: %w", err)
}
s.mu.Lock()
defer s.mu.Unlock()
u := s.userLocked(sub)
if u.Bild != "" && u.Bild != name {
os.Remove(filepath.Join(s.bilderDir(), u.Bild))
}
u.Bild, u.BildURL = name, ""
u.Bildstand = strconv.FormatInt(time.Now().Unix(), 36)
return s.save()
}
// SetBildURL nimmt stattdessen eine fremde Adresse. Eine leere löscht alles.
func (s *Store) SetBildURL(sub, url string) error {
if len(url) > maxFieldLen*4 {
return fmt.Errorf("die Adresse ist zu lang")
}
if url != "" && !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") {
return fmt.Errorf("die Adresse muss mit http:// oder https:// beginnen")
}
s.mu.Lock()
defer s.mu.Unlock()
u := s.userLocked(sub)
if u.Bild != "" {
os.Remove(filepath.Join(s.bilderDir(), u.Bild))
}
u.Bild, u.BildURL = "", url
u.Bildstand = strconv.FormatInt(time.Now().Unix(), 36)
return s.save()
}
// userLocked holt (oder legt an) den Nutzer. Nur mit gehaltenem Schloss.
func (s *Store) userLocked(sub string) *User {
u, ok := s.d.Users[sub]
if !ok {
u = &User{}
s.d.Users[sub] = u
}
return u
}
// DataPath nennt die eine Datei, in der alles steht.
func DataPath(dir string) string { return filepath.Join(dir, "honeyos.json") }