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:
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user