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.
160 lines
4.6 KiB
Go
160 lines
4.6 KiB
Go
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
|
|
}
|