Files
HoneyOS/main.go
T
marc 3807ddca24 Notiz zeigt sich sofort gesetzt, Widgets nach Wunsch des Nutzers
Aus dem Zettel wird die Notiz: Der Text steht immer formatiert da, roh ist nur
die Zeile mit der Schreibmarke. Dazu eine kleine Werkzeugleiste (Aufgabe,
Aufzaehlung, Nummer, Ueberschrift, fett, Linie), Enter fuehrt Listen fort,
Backspace am Zeilenanfang nimmt erst den Vorsatz. Fuer groessere Umbauten
bleibt der Quelltext-Knopf.

Kein contenteditable: Der Quelltext bleibt die Wahrheit und wird nie aus dem
HTML zurueckgerechnet, das der Browser beim Tippen erzeugt.

Reihenfolge und An/Aus der Widgets liegen jetzt je Nutzer auf dem Server, in
einer Einstellung ("benutzer,notiz,-system,wetter,bild"). Der Schalter fuer das
Wetter auf dem Schreibtisch faellt damit weg — es ist ein Widget wie jedes
andere.

Die Einstellungen sind in Abschnitte sortiert (Darstellung, HoneyBar, Widgets,
Wetter, Programme, Konto), Beschriftung links, Schalter rechts.
2026-08-31 01:24:50 +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.2"
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
}