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:
159
internal/web/userstate_api.go
Normal file
159
internal/web/userstate_api.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// maxStateBodyBytes caps read/bookmark request bodies. These carry a single id
|
||||
// and a boolean, so this is generous headroom.
|
||||
const maxStateBodyBytes = 1024
|
||||
|
||||
// maxStateIDs bounds how many ids /api/state will look up in one call. A page
|
||||
// renders a few dozen cards; this is well clear of that.
|
||||
const maxStateIDs = 500
|
||||
|
||||
// requireUser resolves the signed-in user or writes the appropriate error and
|
||||
// returns nil. It mirrors handlePrefs: 404 when auth is disabled entirely, 401
|
||||
// for anonymous callers (the client's cue to stay on localStorage).
|
||||
func (s *Server) requireUser(w http.ResponseWriter, r *http.Request) *SessionUser {
|
||||
if s.auth == nil {
|
||||
http.Error(w, "auth disabled", http.StatusNotFound)
|
||||
return nil
|
||||
}
|
||||
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 nil
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// decodeStateBody reads a small JSON body into dst, writing a 400 on failure.
|
||||
func decodeStateBody(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
return decodeStateBodyN(w, r, dst, maxStateBodyBytes)
|
||||
}
|
||||
|
||||
// decodeStateBodyN is decodeStateBody with an explicit byte cap, for endpoints
|
||||
// (like push subscribe) whose payloads run larger than a single id + flag.
|
||||
func decodeStateBodyN(w http.ResponseWriter, r *http.Request, dst any, limit int64) bool {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, limit+1))
|
||||
if err != nil || int64(len(body)) > limit {
|
||||
http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
if err := json.Unmarshal(body, dst); err != nil {
|
||||
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// handleRead records or clears a story's read flag for the signed-in user.
|
||||
func (s *Server) handleRead(w http.ResponseWriter, r *http.Request) {
|
||||
u := s.requireUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ID int64 `json:"id"`
|
||||
Read bool `json:"read"`
|
||||
}
|
||||
if !decodeStateBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.ID <= 0 {
|
||||
http.Error(w, `{"error":"bad id"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := storage.SetRead(u.Sub, req.ID, req.Read); err != nil {
|
||||
slog.Error("state: set read failed", "sub", u.Sub, "id", req.ID, "err", err)
|
||||
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleBookmark adds or removes a bookmark for the signed-in user.
|
||||
func (s *Server) handleBookmark(w http.ResponseWriter, r *http.Request) {
|
||||
u := s.requireUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ID int64 `json:"id"`
|
||||
On bool `json:"on"`
|
||||
}
|
||||
if !decodeStateBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.ID <= 0 {
|
||||
http.Error(w, `{"error":"bad id"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := storage.SetBookmark(u.Sub, req.ID, req.On); err != nil {
|
||||
slog.Error("state: set bookmark failed", "sub", u.Sub, "id", req.ID, "err", err)
|
||||
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleState returns which of the given story ids are read and bookmarked, so
|
||||
// the client can paint a freshly rendered page in one round-trip.
|
||||
func (s *Server) handleState(w http.ResponseWriter, r *http.Request) {
|
||||
u := s.requireUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
ids := parseIDList(r.URL.Query().Get("ids"))
|
||||
read, bookmarked, err := storage.UserStoryState(u.Sub, ids)
|
||||
if err != nil {
|
||||
slog.Error("state: lookup failed", "sub", u.Sub, "err", err)
|
||||
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"read": keysOf(read),
|
||||
"bookmarked": keysOf(bookmarked),
|
||||
})
|
||||
}
|
||||
|
||||
// parseIDList turns "1,2,3" into a bounded, deduplicated slice of positive ids.
|
||||
func parseIDList(raw string) []int64 {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[int64]bool)
|
||||
out := make([]int64, 0)
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64)
|
||||
if err != nil || id <= 0 || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
out = append(out, id)
|
||||
if len(out) >= maxStateIDs {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// keysOf returns the keys of a set as a slice (order unspecified).
|
||||
func keysOf(m map[int64]bool) []int64 {
|
||||
out := make([]int64, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user