Files
HoneyOS/store.go
T
marcandClaude Opus 5 3b7367944b docs: durchgaengig ID statt Kennung
In der Oberflaeche hiess das Feld Kennung, in der Konfiguration id, in der
README beides. Drei Namen fuer dieselbe Sache, und man muss sie beim Eintragen
zusammenbringen — jetzt ueberall ID.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 15:41:05 +02:00

261 lines
7.0 KiB
Go

package main
import (
"encoding/json"
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
)
// 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.
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"`
Key string `json:"key,omitempty"`
Me string `json:"me,omitempty"`
TokenKey string `json:"tokenKey,omitempty"`
}
type User struct {
Name string `json:"name,omitempty"`
Picture string `json:"picture,omitempty"`
Settings map[string]string `json:"settings,omitempty"`
}
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
)
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 OpenStore(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)
a.Key = strings.TrimSpace(a.Key)
a.Me = strings.TrimSpace(a.Me)
a.TokenKey = strings.TrimSpace(a.TokenKey)
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 = ""
}
if len([]rune(a.Key)) > 1 {
return fmt.Errorf("App %q: Taste ist ein einzelnes Zeichen", a.ID)
}
for _, f := range []string{a.Name, a.Subtitle, a.URL, a.Me, a.TokenKey} {
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()
}
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 Leiste 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 Leiste
// 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
// Leiste 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
}
func dataPath(dir string) string { return filepath.Join(dir, "honeyos.json") }