// HoneyOS — Schreibtisch und Umschalter für die eigenen Apps. // // Ein Binary: der Server hält Apps und Einstellungen, die Oberfläche steckt // per embed mit drin. Angemeldet wird über den eigenen OIDC-Anbieter; ohne // OIDC_ISSUER läuft alles offen — bequem zum Entwickeln, nichts für außen. package main import ( "cmp" "honeyos/internal/auth" "honeyos/internal/config" "honeyos/internal/store" "honeyos/internal/weather" "bytes" "crypto/subtle" "embed" "encoding/json" "io" "io/fs" "log" "net/http" "os" "strconv" "strings" "time" ) //go:embed all:web var webFS embed.FS // glyphen sind die Pixelraster aus web/glyphs.json. Der Schreibtisch holt sich // dieselbe Datei selbst; die HoneyBar in einer fremden App kann das nicht — sie // ist bewusst eine einzelne Datei ohne zweite Anfrage. Also reist das Raster // mit der App-Liste mit. Eine Quelle, zwei Wege, kein Auseinanderlaufen. var glyphen = ladeGlyphen() func ladeGlyphen() map[string][]string { b, err := webFS.ReadFile("web/glyphs.json") if err != nil { log.Printf("glyphs.json fehlt: %v", err) return nil } var g map[string][]string if err := json.Unmarshal(b, &g); err != nil { log.Printf("glyphs.json unlesbar: %v", err) return nil } return g } // Version ist die einzige Stelle, an der sie steht. build.sh liest sie hier aus // und vergibt denselben Docker-Tag; die Oberfläche holt sie über /api/public. // Vorher stand sie zusätzlich in os.js und lief mit den Image-Tags auseinander. const Version = "1.3" type Server struct { cfg config.Config auth *auth.Auth store *store.Store wetter *weather.Dienst } func main() { log.SetFlags(log.LstdFlags) cfg := config.Config{ Addr: config.Env("HONEYOS_ADDR", ":80"), PublicURL: config.Env("HONEYOS_PUBLIC_URL", ""), DataDir: config.Env("HONEYOS_DATA_DIR", "/data"), SessionSecret: config.Env("HONEYOS_SESSION_SECRET", ""), OIDCIssuer: strings.TrimRight(config.Env("HONEYOS_OIDC_ISSUER", ""), "/"), OIDCClientID: config.Env("HONEYOS_OIDC_CLIENT_ID", ""), OIDCClientSecret: config.Env("HONEYOS_OIDC_CLIENT_SECRET", ""), OIDCRedirect: config.Env("HONEYOS_OIDC_REDIRECT_URL", ""), ButtonLabel: config.Env("HONEYOS_BUTTON_LABEL", "Anmelden"), AllowedUsers: splitList(config.Env("HONEYOS_ALLOWED_USERS", "")), ProxyAuth: config.Env("HONEYOS_PROXY_AUTH", "") == "true", UserHeader: config.Env("HONEYOS_USER_HEADER", ""), AppsKey: config.Env("HONEYOS_APPS_KEY", ""), } if cfg.OIDCIssuer != "" { switch { case cfg.SessionSecret == "": log.Fatal("HONEYOS_SESSION_SECRET fehlt — ohne Schlüssel keine Sitzung (openssl rand -hex 32)") case len(cfg.SessionSecret) < 32: log.Fatal("HONEYOS_SESSION_SECRET ist zu kurz — mindestens 32 Zeichen") case cfg.OIDCClientID == "": log.Fatal("HONEYOS_OIDC_CLIENT_ID fehlt") case cfg.PublicURL == "" && cfg.OIDCRedirect == "": log.Fatal("HONEYOS_PUBLIC_URL fehlt — daraus entsteht die Rückrufadresse") } } else if cfg.ProxyAuth { log.Println("Anmeldung: übernimmt der vorgelagerte Proxy (HONEYOS_PROXY_AUTH=true).") if cfg.UserHeader != "" { log.Printf("Nutzername aus der Kopfzeile %s", cfg.UserHeader) } else { log.Println("Kein HONEYOS_USER_HEADER: alle teilen einen Satz Einstellungen.") } log.Println("⚠ HoneyOS prüft dann selbst nichts mehr — es darf nur über den Proxy erreichbar sein.") } else { log.Println("⚠ weder HONEYOS_OIDC_ISSUER noch HONEYOS_PROXY_AUTH: HoneyOS läuft OHNE Anmeldung.") log.Println("⚠ Das ist für die Entwicklung gedacht. Nicht ins offene Netz stellen.") } if cfg.AppsKey == "" && (cfg.ProxyAuth || cfg.OIDCIssuer == "") { log.Println("Hinweis: ohne HONEYOS_APPS_KEY liest die App-Liste jeder, der ihren Pfad kennt.") } if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil { log.Fatalf("Datenverzeichnis %s: %v", cfg.DataDir, err) } st, err := store.Open(store.DataPath(cfg.DataDir)) if err != nil { log.Fatal(err) } s := &Server{cfg: cfg, auth: auth.New(cfg), store: st, wetter: weather.Neu()} srv := &http.Server{ Addr: cfg.Addr, Handler: s.routes(), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, } log.Printf("HoneyOS %s lauscht auf %s (Daten: %s, Anmeldung: %s)", Version, cfg.Addr, store.DataPath(cfg.DataDir), map[bool]string{true: cfg.OIDCIssuer, false: "aus"}[s.auth.Enabled()]) log.Fatal(srv.ListenAndServe()) } func (s *Server) routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") _, _ = w.Write([]byte("ok\n")) }) // Ohne Anmeldung lesbar: nur was der Anmeldeschirm braucht. mux.HandleFunc("GET /api/public", s.handlePublic) mux.HandleFunc("GET /api/auth/login", s.auth.Login) mux.HandleFunc("GET /api/auth/callback", func(w http.ResponseWriter, r *http.Request) { s.auth.Callback(w, r, s.store) }) mux.HandleFunc("POST /api/auth/logout", s.handleLogout) mux.HandleFunc("GET /api/me", s.requireAuth(s.handleMe)) mux.HandleFunc("PUT /api/settings", s.requireAuth(s.handleSetSettings)) // Die Notiz — nur für Angemeldete, in beide Richtungen. Sie steht bewusst // nicht in `appsReadable`: Die HoneyBar in einer fremden App weist sich mit // dem Schlüssel aus, und der kennt keinen Nutzer. Fremde Notizen gehen // niemanden etwas an. mux.HandleFunc("GET /api/note", s.requireAuth(s.handleNote)) mux.HandleFunc("PUT /api/note", s.requireAuth(s.handleSetNote)) // Das Bild: nachsehen, hochladen, verlinken, ausliefern. Vier Wege, weil // „hochgeladene Bytes" und „fremde Adresse" verschiedene Dinge sind und // eine Abzweigung nach Inhaltstyp im selben Endpunkt nur Rätsel aufgäbe. mux.HandleFunc("GET /api/image/info", s.requireAuth(s.handleBildInfo)) mux.HandleFunc("POST /api/image", s.requireAuth(s.handleBildHoch)) mux.HandleFunc("PUT /api/image", s.requireAuth(s.handleBildLink)) mux.HandleFunc("GET /api/image", s.requireAuth(s.handleBild)) mux.HandleFunc("GET /api/apps", s.appsReadable(s.handleApps)) mux.HandleFunc("PUT /api/apps", s.requireAuth(s.handleSetApps)) // Dieselbe Tür wie die App-Liste: Die HoneyBar steht in einer fremden App und // kommt dort nur mit dem Schlüssel an Daten. mux.HandleFunc("GET /api/weather", s.appsReadable(s.handleWeather)) // Die Ortssuche dagegen nur für Angemeldete. Sie reicht einen Suchbegriff an // einen fremden Dienst weiter — offen stünde damit ein Weg, über HoneyOS // beliebig dort anzufragen. mux.HandleFunc("GET /api/weather/search", s.requireAuth(s.handleWeatherSearch)) web, err := fs.Sub(webFS, "web") if err != nil { log.Fatal(err) } files := http.FileServer(http.FS(web)) index := s.indexHTML(web) // Die Startseite kommt aus dem Speicher, weil der Schlüssel hineingehört. // Alles andere liefert der Dateiserver unverändert aus. mux.Handle("GET /", noCache(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" || r.URL.Path == "/index.html" { w.Header().Set("Content-Type", "text/html; charset=utf-8") http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(index)) return } files.ServeHTTP(w, r) }))) return s.cors(mux) } // indexHTML holt die Startseite aus dem Binary und legt den Schlüssel als // Meta-Angabe hinein. Die Seite selbst ist im Proxy geschützt; wer sie sieht, // darf die Liste ohnehin lesen. func (s *Server) indexHTML(web fs.FS) []byte { raw, err := fs.ReadFile(web, "index.html") if err != nil { log.Fatalf("index.html fehlt im Binary: %v", err) } if s.cfg.AppsKey == "" { return raw } meta := `` return bytes.Replace(raw, []byte(""), []byte(" "+meta+"\n "), 1) } // htmlAttr entschärft den Wert für ein Attribut in doppelten Anführungszeichen. func htmlAttr(v string) string { r := strings.NewReplacer(`&`, "&", `"`, """, `<`, "<", `>`, ">") return r.Replace(v) } /* ── Zwischenschichten ───────────────────────────────────────────────────── */ // cors öffnet die HoneyBar für die eingetragenen Apps — und nur für die. Die // App-Liste ist damit zugleich die Freigabeliste; wer nicht darin steht, kommt // auch nicht an die Sitzung. func (s *Server) cors(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") if origin != "" && s.store.AppOrigins()[origin] { h := w.Header() h.Set("Access-Control-Allow-Origin", origin) h.Set("Access-Control-Allow-Credentials", "true") h.Set("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token, X-HoneyOS-Key") h.Set("Access-Control-Allow-Methods", "GET, PUT, POST") h.Add("Vary", "Origin") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } } next.ServeHTTP(w, r) }) } func noCache(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // no-store, nicht no-cache: Die Oberflaeche und vor allem honeybar.js // muessen bei jedem Aufruf frisch kommen. Sie werden ueber den eigenen // Ursprung der fremden App ausgeliefert und geraten damit in deren // Zwischenspeicher — und in den ihres Service Workers, falls sie einen // hat. Eine alte HoneyBar dort ist von aussen nicht zu erkennen: Sie // sieht aus wie ein Fehler in HoneyOS. w.Header().Set("Cache-Control", "no-store, must-revalidate") // Die HoneyBar wird von fremden Ursprüngen geladen — das ist ihr Zweck. // Apps, die selbst COEP „require-corp" setzen (alles mit WASM oder // SharedArrayBuffer, etwa BentoPDF), lehnen sonst jede fremde Datei ab, // die sich nicht ausdrücklich dazu bekennt. Ohne diese Zeile erscheint // dort keine HoneyBar, und im Fehlerprotokoll steht nur ein Kürzel. w.Header().Set("Cross-Origin-Resource-Policy", "cross-origin") next.ServeHTTP(w, r) }) } // appsReadable lässt die App-Liste zusätzlich mit dem Schlüssel durch. // // Der Grund ist eine Browserregel, keine Bequemlichkeit: Liegt HoneyOS auf // einer anderen Site als die Apps, schickt der Browser die Sitzung bei der // Anfrage aus einer App heraus nicht mit. Ohne einen zweiten Ausweis bekäme die // HoneyBar dort nie eine Liste. Der Schlüssel steht im Skript-Tag der App und ist // damit nur für den lesbar, der ohnehin an die App herankommt. func (s *Server) appsReadable(next func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // Erst die Sitzung, dann der Schlüssel. Umgekehrt wäre bequemer zu // lesen, aber falsch: Der Schlüssel kennt keinen Nutzer und landet // beim Standardnutzer. Liegt die App unter derselben Site wie HoneyOS // (`cloud.honeyos.…` und `honeyos.…` sind dieselbe), schickt der // Browser die Sitzung längst mit — dann soll die HoneyBar dort auch // die Farben, das Wetter und später die Reihenfolge **dieses** Nutzers // bekommen und nicht die eines fremden. // // Nur bei eigener Anmeldung: Ohne sie gilt jede Anfrage als angemeldet // (offener Betrieb, Proxy davor), und das hier wäre eine offene Tür. if s.auth.Enabled() { if sub, ok := s.auth.SubFromRequest(r); ok { next(w, r, sub) return } } if key := s.cfg.AppsKey; key != "" { given := r.Header.Get("X-HoneyOS-Key") if given == "" { given = r.URL.Query().Get("key") } if subtle.ConstantTimeCompare([]byte(given), []byte(key)) == 1 { next(w, r, store.LocalSub) return } // Ohne eigene Anmeldung gibt es keine Sitzung, die den Schlüssel // ersetzen könnte: dort gilt jede Anfrage als angemeldet, und der // Rückfall würde die Liste an jeden herausgeben. Hier ist Schluss. if !s.auth.Enabled() { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Schlüssel fehlt oder passt nicht"}) return } } s.requireAuth(next)(w, r) } } func (s *Server) requireAuth(next func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { sub, ok := s.auth.SubFromRequest(r) if !ok { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "nicht angemeldet"}) return } if r.Method != http.MethodGet { if err := s.auth.CheckCSRF(r); err != nil { writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()}) return } } next(w, r, sub) } } /* ── Handler ─────────────────────────────────────────────────────────────── */ func (s *Server) handlePublic(w http.ResponseWriter, r *http.Request) { _, signedIn := s.auth.SubFromRequest(r) writeJSON(w, http.StatusOK, map[string]any{ "oidc": s.auth.Enabled(), "buttonLabel": s.cfg.ButtonLabel, "signedIn": signedIn, "version": Version, }) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { s.auth.ClearSession(w, r) w.WriteHeader(http.StatusNoContent) } func (s *Server) handleMe(w http.ResponseWriter, r *http.Request, sub string) { u := s.store.User(sub) writeJSON(w, http.StatusOK, map[string]any{ "sub": sub, "name": cmp.Or(u.Name, "marc"), "picture": u.Picture, "settings": u.Settings, "oidc": s.auth.Enabled(), "version": Version, }) } func (s *Server) handleSetSettings(w http.ResponseWriter, r *http.Request, sub string) { var in map[string]string if err := decode(r, &in); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } if err := s.store.SetSettings(sub, in); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } w.WriteHeader(http.StatusNoContent) } // handleApps liefert die Liste **und** das Aussehen. Beides zusammen, weil die // HoneyBar in einer fremden App genau eine Anfrage stellen kann: Sie liegt oft auf // einer anderen Site, kommt also nur mit dem Schlüssel an Daten und hat keine // Sitzung, aus der sich der Rest ergäbe. // // Welche Einstellungen? Die des Angemeldeten, wenn es einen gibt — sonst die des // Standardnutzers. Bei mehreren Nutzern trägt die HoneyBar damit die Farbe des // Standardnutzers; für eine Person stimmt es immer. func (s *Server) handleApps(w http.ResponseWriter, r *http.Request, sub string) { writeJSON(w, http.StatusOK, map[string]any{ "apps": s.store.Apps(), "settings": s.store.SettingsFor(sub), "glyphs": glyphen, }) } // handleWeather liefert das Wetter für den eingestellten Ort — oder nichts. // // „Nichts" ist hier ein 204 und kein Fehler: Kein Ort eingestellt ist der // Normalfall, solange niemand einen gewählt hat, und die HoneyBar soll dafür // nicht rot blinken, sondern das Symbol einfach weglassen. Dasselbe gilt, wenn // der Dienst nicht erreichbar ist: lieber kein Wetter als eine Zahl von gestern. func (s *Server) handleNote(w http.ResponseWriter, r *http.Request, sub string) { writeJSON(w, http.StatusOK, map[string]string{"text": s.store.Note(sub)}) } func (s *Server) handleSetNote(w http.ResponseWriter, r *http.Request, sub string) { var in struct { Text string `json:"text"` } if err := decode(r, &in); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } if err := s.store.SetNote(sub, in.Text); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } w.WriteHeader(http.StatusNoContent) } /* ── Das Bild ────────────────────────────────────────────────────────────── * * Genau eines je Nutzer, entweder hochgeladen oder verlinkt. Der Ausweis ist * immer die Sitzung: Bilder gehen weder über den App-Schlüssel noch an fremde * Ursprünge — es ist das persönlichste, was HoneyOS speichert. */ // bildTypen: was hereindarf, und mit welcher Endung es abgelegt wird. // // **SVG steht bewusst nicht dabei.** Es ist Text, kann Skripte enthalten und // würde, aus derselben Adresse ausgeliefert, im Namen der Seite laufen. Ein // Bildwidget ist diesen Weg nicht wert. var bildTypen = map[string]string{ "image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp", "image/gif": ".gif", } const maxBild = 3 << 20 // 3 MB func (s *Server) handleBildInfo(w http.ResponseWriter, r *http.Request, sub string) { datei, url, stand := s.store.Bild(sub) switch { case datei != "": writeJSON(w, http.StatusOK, map[string]string{"art": "datei", "url": "/api/image?v=" + stand}) case url != "": writeJSON(w, http.StatusOK, map[string]string{"art": "link", "url": url}) default: writeJSON(w, http.StatusOK, map[string]string{"art": "", "url": ""}) } } func (s *Server) handleBildHoch(w http.ResponseWriter, r *http.Request, sub string) { defer r.Body.Close() daten, err := io.ReadAll(io.LimitReader(r.Body, maxBild+1)) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Bild nicht lesbar"}) return } if len(daten) > maxBild { writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "Das Bild ist größer als 3 MB"}) return } // Der eigene Blick auf die Bytes, nicht die Angabe des Browsers: Wer // hochlädt, bestimmt sonst den Typ — und damit, was ausgeliefert wird. typ := strings.SplitN(http.DetectContentType(daten), ";", 2)[0] endung, ok := bildTypen[typ] if !ok { writeJSON(w, http.StatusUnsupportedMediaType, map[string]string{ "error": "Nur PNG, JPEG, WebP oder GIF — erkannt wurde " + typ, }) return } if err := s.store.SetBildDatei(sub, endung, daten); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } w.WriteHeader(http.StatusNoContent) } func (s *Server) handleBildLink(w http.ResponseWriter, r *http.Request, sub string) { var in struct { URL string `json:"url"` } if err := decode(r, &in); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } if err := s.store.SetBildURL(sub, strings.TrimSpace(in.URL)); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } w.WriteHeader(http.StatusNoContent) } func (s *Server) handleBild(w http.ResponseWriter, r *http.Request, sub string) { pfad, ok := s.store.BildPfad(sub) if !ok { http.NotFound(w, r) return } // Der Versionsanteil steht in der Adresse, also darf der Browser es lange // behalten: Ein neues Bild bekommt eine neue Adresse. w.Header().Set("Cache-Control", "private, max-age=604800") http.ServeFile(w, r, pfad) } func (s *Server) handleWeather(w http.ResponseWriter, r *http.Request, sub string) { set := s.store.SettingsFor(sub) lat, err1 := strconv.ParseFloat(set["weatherLat"], 64) lon, err2 := strconv.ParseFloat(set["weatherLon"], 64) if set["weatherCity"] == "" || err1 != nil || err2 != nil { w.WriteHeader(http.StatusNoContent) return } wetter, err := s.wetter.Jetzt(r.Context(), set["weatherCity"], lat, lon) if err != nil { log.Printf("wetter: %v", err) // 502, nicht 204: Ein Ort ist eingestellt, es ist also etwas zu zeigen — // der Dienst ist nur gerade nicht da. Die Oberflaeche unterscheidet das // seit dem Widget-Rahmen: „nichts eingestellt" verschwindet still, // „nicht erreichbar" sagt es. Beides als 204 zu melden hiess, dass ein // Ausfall aussah wie ein abgeschaltetes Widget. writeJSON(w, http.StatusBadGateway, map[string]string{"error": "Wetterdienst nicht erreichbar"}) return } writeJSON(w, http.StatusOK, wetter) } func (s *Server) handleWeatherSearch(w http.ResponseWriter, r *http.Request, sub string) { orte, err := s.wetter.Suche(r.Context(), r.URL.Query().Get("q")) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]string{"error": "Ortssuche nicht erreichbar"}) return } writeJSON(w, http.StatusOK, orte) } func (s *Server) handleSetApps(w http.ResponseWriter, r *http.Request, sub string) { var in []store.App if err := decode(r, &in); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } if err := s.store.SetApps(in); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } s.handleApps(w, r, sub) } /* ── Kleinkram ───────────────────────────────────────────────────────────── */ func decode(r *http.Request, into any) error { defer r.Body.Close() dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 64<<10)) return dec.Decode(into) } func writeJSON(w http.ResponseWriter, code int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(v) } func splitList(s string) []string { var out []string for _, p := range strings.Split(s, ",") { if p = strings.TrimSpace(p); p != "" { out = append(out, p) } } return out }