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>
This commit is contained in:
1 parent
84cc5d619e
commit
283f9a1850
20 files changed
+1831
-438
No files matched your search
@@ -0,0 +1,225 @@
|
||||
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") }
|
||||
Reference in new issue
Block a user