Files
HoneyOS/store.go
T
marcandClaude Opus 5 283f9a1850 feat: Backend mit OIDC-Anmeldung, Apps und Einstellungen im Server
Aus der statischen Seite wird ein Go-Binary mit eingebetteter Oberfläche.
Grund: Apps über .env zu pflegen wurde unübersichtlich, und kosmetische
Einstellungen sollen auf jedem Gerät gleich sein.

- Anmeldung über den eigenen OIDC-Anbieter (PocketID), mit PKCE. Ohne
  Anmeldung gibt es nichts zu sehen – auch keine App-Liste.
- Sitzung als signiertes Cookie ohne Gegenstück im Server; Schreibzugriffe
  zusätzlich über ein lesbares CSRF-Cookie abgesichert
- Apps werden in der Oberfläche angelegt und bearbeitet, nicht mehr in der
  .env. Der Server prüft jeden Eintrag und meldet zurück, was er behält.
- Einstellungen liegen je Nutzer im Server; localStorage ist nur noch der
  Vorschuss, damit die Röhre beim Laden nicht in der falschen Farbe blitzt
- die Leiste holt die App-Liste aus /api/apps – freigegeben sind allein die
  Ursprünge, die selbst als App eingetragen sind
- Tests fahren einen echten OIDC-Anbieter hoch und gehen den Anmeldeweg
  einmal ganz durch

Ohne HONEYOS_OIDC_ISSUER läuft alles offen – für die Entwicklung, mit
deutlicher Warnung beim Start.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 15:20:55 +02:00

226 lines
5.9 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()
}
// 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") }