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: Kennung muss klein geschrieben sein und mit Buchstabe oder Ziffer beginnen", i+1) } if seen[a.ID] { return fmt.Errorf("Kennung %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() } // 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") }