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.
This commit is contained in:
74
internal/web/prefs_api.go
Normal file
74
internal/web/prefs_api.go
Normal file
@@ -0,0 +1,74 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user