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:
prosolis
2026-06-21 15:44:53 -07:00
parent 1a43616248
commit cbbedd9894
18 changed files with 722 additions and 32 deletions

View File

@@ -43,24 +43,28 @@ func toView(s storage.Story) StoryView {
}
type pageData struct {
SiteTitle string
Channels []Channel
Active string // slug of active channel, "" for landing
Weather Weather
Phase string // when set, forces day/dawn/dusk/night and disables the clock-driven phase JS
AllSources template.JS // JSON array of {name, channel} for the settings panel
SiteTitle string
Channels []Channel
Active string // slug of active channel, "" for landing
Weather Weather
Phase string // when set, forces day/dawn/dusk/night and disables the clock-driven phase JS
AllSources template.JS // JSON array of {name, channel} for the settings panel
AuthEnabled bool // sign-in is available
User *SessionUser // nil for anonymous visitors
UserPrefs template.JS // signed-in user's stored prefs blob (JSON), or "null"
Path string // current request path, for post-login return
}
type channelPage struct {
pageData
Channel Channel
Stories []StoryView
Page int
HasPrev bool
HasNext bool
PrevURL string
NextURL string
Total int
Channel Channel
Stories []StoryView
Page int
HasPrev bool
HasNext bool
PrevURL string
NextURL string
Total int
}
type indexPage struct {
@@ -78,20 +82,32 @@ type channelStat struct {
Total int
}
func (s *Server) base() pageData {
func (s *Server) base(r *http.Request) pageData {
srcJSON, err := json.Marshal(s.sources)
if err != nil {
srcJSON = []byte("[]")
}
return pageData{
SiteTitle: s.cfg.SiteTitle,
Channels: channels,
Weather: currentWeather(time.Now()),
AllSources: template.JS(srcJSON),
d := pageData{
SiteTitle: s.cfg.SiteTitle,
Channels: channels,
Weather: currentWeather(time.Now()),
AllSources: template.JS(srcJSON),
AuthEnabled: s.auth != nil,
UserPrefs: template.JS("null"),
Path: r.URL.Path,
}
if s.auth != nil {
if u := s.auth.userFromRequest(r); u != nil {
d.User = u
if blob, err := storage.GetUserPrefs(u.Sub); err == nil && blob != "" {
d.UserPrefs = template.JS(blob)
}
}
}
return d
}
func (s *Server) handleIndex(w http.ResponseWriter, _ *http.Request) {
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
const (
justPostedLimit = 6
latestLimit = 16
@@ -137,7 +153,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, _ *http.Request) {
}
data := indexPage{
pageData: s.base(),
pageData: s.base(r),
JustPosted: justPosted,
Stats: stats,
Latest: latest,
@@ -169,7 +185,7 @@ func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channe
}
total, _ := storage.CountClassifiedByChannel(ch.Slug)
base := s.base()
base := s.base(r)
base.Active = ch.Slug
data := channelPage{
pageData: base,
@@ -230,7 +246,7 @@ func (s *Server) handleWeatherDemo(w http.ResponseWriter, r *http.Request) {
intensity := pickOr(q.Get("intensity"), demoIntensities, "heavy")
phase := pickOr(q.Get("phase"), demoPhases, "day")
base := s.base()
base := s.base(r)
base.Weather = Weather{Season: seasonForVariant(variant), Variant: variant, Intensity: intensity}
base.Phase = phase