Marcs Entscheidung: In der HoneyBar fremder Apps steht kein Abmelden mehr — es war dort ohnehin nur ein Link auf den Schreibtisch. Als einzige Schaltflaeche, die eine Sitzung beendet, steht es jetzt an genau einer Stelle: unten in den Einstellungen, sichtbar nur bei eigener Anmeldung. Damit faellt auch der ?abmelden=1-Pfad wieder weg und /api/apps braucht das oidc-Feld nicht mehr. Das Zahnrad rechts in der Menueleiste ist durch den Pfeil mit der App-Liste ersetzt — derselbe wie in der HoneyBar. Beide Leisten tragen damit dieselben vier Dinge: Logo, Apps, Wetter/Uhr, Aufklappmenue.
391 lines
14 KiB
Go
391 lines
14 KiB
Go
// 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/fs"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
//go:embed all:web
|
|
var webFS embed.FS
|
|
|
|
// 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.1"
|
|
|
|
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))
|
|
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 := `<meta name="honeyos-key" content="` + htmlAttr(s.cfg.AppsKey) + `" />`
|
|
return bytes.Replace(raw, []byte("</head>"), []byte(" "+meta+"\n </head>"), 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) {
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
// 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) {
|
|
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),
|
|
})
|
|
}
|
|
|
|
// 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) 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)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
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
|
|
}
|