Files
Pete/internal/web/prefs_api.go
prosolis cbbedd9894 Add optional Authentik (OIDC) sign-in with server-side preference sync
Signed-in users get their preferences (hidden feeds, weather location,
weather toggle) stored server-side keyed by their OIDC subject and synced
across devices. Anonymous visitors keep using browser localStorage, so the
site stays public. First sign-in migrates existing localStorage prefs up.

- config: [web.auth] section (issuer, client_id/secret, redirect, session_secret)
- storage: user_preferences table + Get/PutUserPrefs
- web/auth: OIDC code flow, HMAC-signed session cookie, CSRF state + nonce
- web/prefs_api: GET/PUT /api/preferences (auth-gated, 64KB cap)
- frontend: prefs.js sync layer seeds localStorage from server, pushes on write
- header: sign-in / account control

OIDC discovery is non-fatal at boot: if Authentik is down, Pete serves
anonymously rather than refusing to start.
2026-06-21 15:44:53 -07:00

75 lines
2.2 KiB
Go

package web
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"pete/internal/storage"
)
// maxPrefsBytes caps the stored blob. Preferences are small (disabled feeds,
// a postal code, a couple toggles); this is generous headroom, not a target.
const maxPrefsBytes = 64 * 1024
// handlePrefs serves GET (load) and PUT (save) of the signed-in user's
// preferences blob. The blob is opaque JSON owned by the frontend. Both verbs
// require a session; anonymous callers get 401 and fall back to localStorage.
func (s *Server) handlePrefs(w http.ResponseWriter, r *http.Request) {
if s.auth == nil {
http.Error(w, "auth disabled", http.StatusNotFound)
return
}
u := s.auth.userFromRequest(r)
if u == nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
http.Error(w, `{"error":"not signed in"}`, http.StatusUnauthorized)
return
}
switch r.Method {
case http.MethodGet:
blob, err := storage.GetUserPrefs(u.Sub)
if err != nil {
slog.Error("prefs: load failed", "sub", u.Sub, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if blob == "" {
_, _ = w.Write([]byte(`{"prefs":null}`))
return
}
// blob is already valid JSON; embed it verbatim.
_, _ = w.Write([]byte(`{"prefs":`))
_, _ = io.WriteString(w, blob)
_, _ = w.Write([]byte(`}`))
case http.MethodPut:
body, err := io.ReadAll(io.LimitReader(r.Body, maxPrefsBytes+1))
if err != nil {
http.Error(w, "read error", http.StatusBadRequest)
return
}
if len(body) > maxPrefsBytes {
http.Error(w, "preferences too large", http.StatusRequestEntityTooLarge)
return
}
if !json.Valid(body) {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
if err := storage.PutUserPrefs(u.Sub, string(body), u.Name, u.Email); err != nil {
slog.Error("prefs: save failed", "sub", u.Sub, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}