// 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") }