Files
marc 3f8b98b869 Eine Biene als Wappen, an allen vier Stellen
Aus dem Quadrat links in beiden Leisten wird eine Pixelbiene von der Seite:
braune Streifen, schwarze Beine, Fluegel mit Kerbe. Sie steht in genau einem
Raster (tools/biene.py, 16x16 Zeichen); daraus entstehen SVG, PNG und eine
Fassung aus Schriftzeichen.

SVG statt PNG in den Leisten: Die HoneyBar laeuft in fremden Apps, und ob ein
<img> dort laedt, entscheidet deren CSP. Markup kann niemand blockieren.

Im System-Fenster und ueber dem Startlauf steht die Biene aus Schriftzeichen —
beides sind Textflaechen, ein Bild darin waere ein Fremdkoerper.

Das Wappen liegt an drei Stellen (Datei, Modul, HoneyBar-Zeile), weil die
Leiste nichts nachladen kann. TestWappenIstUeberallDasselbe haelt sie gleich.
2026-09-02 10:21:03 +02:00

561 lines
21 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"
"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 := `<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(`&`, "&amp;", `"`, "&quot;", `<`, "&lt;", `>`, "&gt;")
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
}