Marcs Frage: gilt die Einstellung vom Rechner auch für die Leiste am Handy? Sie hätte es nicht getan, sobald HONEYOS_USER_HEADER gesetzt ist: die Einstellungen liegen dann unter "proxy:marc", die Leiste fragt aber ohne Sitzung und landet beim Standardnutzer — dessen Satz ist leer, die Leiste wäre bernsteinfarben geblieben, während der Schreibtisch grün leuchtet. SettingsFor() hilft nach: gibt es genau einen Nutzer mit Einstellungen, gelten dessen. Bei mehreren bleibt es beim Standard, dann ist nicht zu erraten, wer da schaut. Nachgestellt mit einem Pixel-7-Profil ohne jedes Cookie: die Leiste kommt grün an. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
261 lines
7.0 KiB
Go
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: 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()
|
|
}
|
|
|
|
// 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") }
|