Add personalization, outbound feeds, and PWA/push to the web UI

A multi-session build turning Pete's read-only web UI into something people
return to. Five phases, signed-in features keyed off the OIDC subject; anonymous
visitors keep the reverse-chron feed and localStorage-only state.

Phase 1 — per-user read + bookmark state: user_story_state table +
storage/userstate.go; auth-gated /api/read, /api/bookmark, /api/state and a
/bookmarks page; reader.js syncs state server-side for signed-in users. Also
hides the Matrix-posting UI when posting.enabled=false (web-only mode).

Phase 2 — outbound feeds: storage.ListForFeed + web/feed.go hand-build RSS 2.0
(content:encoded) and JSON Feed 1.1 (no new dep); /feed.xml, /feed.json and
per-channel variants; <link rel=alternate> discovery tags.

Phase 3 — "For you" + related: storage/rank.go scores recent unread candidates
by channel/source affinity + recency decay; RelatedStories via FTS5. ForYou rail
+ /for-you page; public /api/related feeds the reader's "You might also like".

Phase 4 — source-health dashboard: source_health table + storage/sourcehealth.go
(RecordPollResult, ListSourceHealth, SourceContentStats), written by the poller;
admin-gated /status page behind web.admin_subs.

Phase 5 — PWA + offline reader + Web Push: root-scoped manifest.webmanifest and
sw.js (app-shell precache, /api/article runtime cache for offline reading,
offline fallback, push/notificationclick handlers); PNG icons from pete.avif;
pwa.js registers the SW and drives a notifications toggle. Web Push adds
webpush-go, a [web.push] config block (pete -genvapid mints VAPID keys), a
push_subscriptions table, auth-gated subscribe/unsubscribe endpoints, and a
digest sender that pings each subscriber "N new stories" past their watermark,
honoring disabled-sources and pruning gone endpoints.

Tests added beside each new storage/web file; go test ./... and go vet clean.
This commit is contained in:
prosolis
2026-07-07 00:07:19 -07:00
parent 55aa167151
commit 71f7050f41
45 changed files with 3622 additions and 36 deletions

View File

@@ -52,11 +52,13 @@ type SourceInfo struct {
// Server is the HTTP server for Pete's web UI.
type Server struct {
cfg config.WebConfig
sources []SourceInfo
srv *http.Server
tpls map[string]*template.Template // keyed by page name (e.g. "index", "channel")
auth *Authenticator // nil when sign-in is disabled or unavailable
cfg config.WebConfig
sources []SourceInfo
postingEnabled bool // false = web-only mode; hides Matrix-posting UI
srv *http.Server
tpls map[string]*template.Template // keyed by page name (e.g. "index", "channel")
auth *Authenticator // nil when sign-in is disabled or unavailable
adminSubs map[string]bool // OIDC subjects allowed to view /status
// Daily-rotated salt for the privacy-preserving unique-visitor estimate.
// Guarded by metricsMu; never persisted (see metrics.go).
@@ -68,8 +70,8 @@ type Server struct {
// New builds the server. Templates are parsed once at startup. Each page
// gets its own template set sharing layout.html + _card.html, which avoids
// `main` define collisions between pages.
func New(cfg config.WebConfig, sources []config.SourceConfig) (*Server, error) {
pages := []string{"index", "channel", "weather"}
func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled bool) (*Server, error) {
pages := []string{"index", "channel", "weather", "bookmarks", "for-you", "status"}
tpls := make(map[string]*template.Template, len(pages))
for _, p := range pages {
t, err := template.New("").Funcs(funcs).ParseFS(templateFS,
@@ -89,7 +91,13 @@ func New(cfg config.WebConfig, sources []config.SourceConfig) (*Server, error) {
}
infos = append(infos, SourceInfo{Name: sc.Name, Channel: sc.DirectRoute})
}
s := &Server{cfg: cfg, sources: infos, tpls: tpls}
adminSubs := make(map[string]bool, len(cfg.AdminSubs))
for _, sub := range cfg.AdminSubs {
if sub != "" {
adminSubs[sub] = true
}
}
s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs}
// Optional OIDC sign-in (Authentik). Discovery is a network call; if the
// provider is unreachable at boot we log and serve anonymously rather than
@@ -115,15 +123,23 @@ func New(cfg config.WebConfig, sources []config.SourceConfig) (*Server, error) {
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
mux.HandleFunc("GET /img/{name}", s.handleImg)
mux.HandleFunc("GET /manifest.webmanifest", s.handleManifest)
mux.HandleFunc("GET /sw.js", s.handleServiceWorker)
mux.HandleFunc("GET /{$}", s.handleIndex)
mux.HandleFunc("GET /weather", s.handleWeatherDemo)
mux.HandleFunc("GET /api/search", s.handleSearch)
mux.HandleFunc("GET /api/article", s.handleArticle)
mux.HandleFunc("GET /api/related", s.handleRelated)
mux.HandleFunc("GET /feed.xml", func(w http.ResponseWriter, r *http.Request) { s.handleFeedXML(w, r, "") })
mux.HandleFunc("GET /feed.json", func(w http.ResponseWriter, r *http.Request) { s.handleFeedJSON(w, r, "") })
for _, ch := range channels {
ch := ch
mux.HandleFunc("GET /"+ch.Slug, func(w http.ResponseWriter, r *http.Request) {
s.handleChannel(w, r, ch)
})
mux.HandleFunc("GET /"+ch.Slug+"/feed.xml", func(w http.ResponseWriter, r *http.Request) { s.handleFeedXML(w, r, ch.Slug) })
mux.HandleFunc("GET /"+ch.Slug+"/feed.json", func(w http.ResponseWriter, r *http.Request) { s.handleFeedJSON(w, r, ch.Slug) })
}
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
@@ -136,6 +152,16 @@ func New(cfg config.WebConfig, sources []config.SourceConfig) (*Server, error) {
mux.HandleFunc("GET /auth/logout", s.auth.handleLogout)
mux.HandleFunc("GET /api/preferences", s.handlePrefs)
mux.HandleFunc("PUT /api/preferences", s.handlePrefs)
mux.HandleFunc("POST /api/read", s.handleRead)
mux.HandleFunc("POST /api/bookmark", s.handleBookmark)
mux.HandleFunc("GET /api/state", s.handleState)
mux.HandleFunc("GET /bookmarks", s.handleBookmarks)
mux.HandleFunc("GET /for-you", s.handleForYou)
mux.HandleFunc("GET /status", s.handleStatus)
if s.cfg.Push.Enabled {
mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
mux.HandleFunc("POST /api/push/unsubscribe", s.handlePushUnsubscribe)
}
}
s.srv = &http.Server{