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.
75 lines
2.2 KiB
Go
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)
|
|
}
|
|
}
|