Files
Pete/internal/web/handlers.go
prosolis 71f7050f41 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.
2026-07-07 00:07:19 -07:00

590 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"encoding/json"
"fmt"
"html/template"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"pete/internal/storage"
)
const pageSize = 24
// StoryView is the trimmed-down record used in templates.
type StoryView struct {
ID int64
Headline string
Lede string
ImageURL string
ArticleURL string
Source string
SeenAt time.Time
Posted bool
Paywalled bool // source is gated and no archive workaround succeeded
Channel string // channel slug; also the theme key
}
func toView(s storage.Story) StoryView {
return StoryView{
ID: s.ID,
Headline: s.Headline,
Lede: s.Lede,
ImageURL: s.ImageURL,
ArticleURL: s.ArticleURL,
Source: s.Source,
SeenAt: time.Unix(s.SeenAt, 0),
Posted: s.Posted,
Paywalled: s.Paywalled,
Channel: s.Channel,
}
}
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
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
PostingEnabled bool // false = web-only mode; hide Matrix-posting UI
IsAdmin bool // signed-in user is on the admin allowlist (shows /status link)
PushEnabled bool // Web Push is configured (shows the notifications toggle to signed-in users)
PushPublicKey string // VAPID public key handed to the client to subscribe
}
type channelPage struct {
pageData
Channel Channel
Stories []StoryView
Page int
HasPrev bool
HasNext bool
PrevURL string
NextURL string
Total int
}
type indexPage struct {
pageData
JustPosted []StoryView
Stats []channelStat
Latest []StoryView
ForYou []StoryView // personalized rail; empty for anon / no-history users
}
type channelStat struct {
Channel Channel
LastPostedAt time.Time // zero if never posted
HasLastPosted bool
PostsToday int
Total int
}
// jsForScript marks already-valid JSON as safe to embed in an inline <script>,
// after neutralizing the byte sequences that could break out of that context
// (</script>, HTML comments, and the JS line separators U+2028/U+2029). Without
// this, a stored prefs blob containing "</script>" could inject markup.
func jsForScript(b []byte) template.JS {
r := strings.NewReplacer(
"<", "\\u003c",
">", "\\u003e",
"&", "\\u0026",
"", "\\u2028",
"", "\\u2029",
)
return template.JS(r.Replace(string(b)))
}
func (s *Server) base(r *http.Request) pageData {
srcJSON, err := json.Marshal(s.sources)
if err != nil {
srcJSON = []byte("[]")
}
d := pageData{
SiteTitle: s.cfg.SiteTitle,
Channels: channels,
Weather: currentWeather(time.Now()),
AllSources: jsForScript(srcJSON),
AuthEnabled: s.auth != nil,
UserPrefs: template.JS("null"),
Path: r.URL.Path,
PostingEnabled: s.postingEnabled,
PushEnabled: s.auth != nil && s.cfg.Push.Enabled,
PushPublicKey: s.cfg.Push.VAPIDPublicKey,
}
if s.auth != nil {
if u := s.auth.userFromRequest(r); u != nil {
d.User = u
d.IsAdmin = s.adminSubs[u.Sub]
if blob, err := storage.GetUserPrefs(u.Sub); err == nil && blob != "" {
d.UserPrefs = jsForScript([]byte(blob))
}
}
}
return d
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
s.track(r, "home")
const (
justPostedLimit = 6
latestLimit = 16
)
postedRows, err := storage.ListRecentlyPosted(justPostedLimit)
if err != nil {
slog.Error("web: list recently posted failed", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
justPosted := make([]StoryView, 0, len(postedRows))
for _, r := range postedRows {
justPosted = append(justPosted, toView(r))
}
latestRows, err := storage.ListAllClassified(latestLimit, 0)
if err != nil {
slog.Error("web: list all classified failed", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
latest := make([]StoryView, 0, len(latestRows))
for _, r := range latestRows {
latest = append(latest, toView(r))
}
dayStart := time.Now().Add(-24 * time.Hour).Unix()
stats := make([]channelStat, 0, len(channels))
for _, ch := range channels {
total, _ := storage.CountClassifiedByChannel(ch.Slug)
lastTs := storage.GetLastPostTime(ch.Slug)
stat := channelStat{
Channel: ch,
PostsToday: storage.CountPostsInWindow(ch.Slug, dayStart),
Total: total,
}
if lastTs > 0 {
stat.LastPostedAt = time.Unix(lastTs, 0)
stat.HasLastPosted = true
}
stats = append(stats, stat)
}
data := indexPage{
pageData: s.base(r),
JustPosted: justPosted,
Stats: stats,
Latest: latest,
}
// For signed-in users with some read/bookmark history, lead with a small
// personalized rail. ForYou returns nothing for anon / no-history users, so
// the section simply doesn't render for them.
if s.auth != nil {
if u := s.auth.userFromRequest(r); u != nil {
const forYouLimit = 8
if fy, err := storage.ForYou(u.Sub, forYouLimit); err != nil {
slog.Error("web: for-you rail failed", "sub", u.Sub, "err", err)
} else {
for _, row := range fy {
data.ForYou = append(data.ForYou, toView(row))
}
}
}
}
s.render(w, "index", data)
}
type forYouPage struct {
pageData
Stories []StoryView
}
// handleForYou renders the dedicated personalized feed. Anonymous visitors are
// sent to sign-in (the route is only registered when auth is on).
func (s *Server) handleForYou(w http.ResponseWriter, r *http.Request) {
u := s.auth.userFromRequest(r)
if u == nil {
http.Redirect(w, r, "/auth/login?next=/for-you", http.StatusSeeOther)
return
}
s.track(r, "for-you")
const limit = 30
rows, err := storage.ForYou(u.Sub, limit)
if err != nil {
slog.Error("web: for-you page failed", "sub", u.Sub, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
views := make([]StoryView, 0, len(rows))
for _, row := range rows {
views = append(views, toView(row))
}
base := s.base(r)
base.Active = "for-you"
s.render(w, "for-you", forYouPage{pageData: base, Stories: views})
}
func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channel) {
s.track(r, ch.Slug)
page := 1
if p := r.URL.Query().Get("page"); p != "" {
if n, err := strconv.Atoi(p); err == nil && n > 0 {
page = n
}
}
offset := (page - 1) * pageSize
rows, err := storage.ListClassifiedByChannel(ch.Slug, pageSize+1, offset)
if err != nil {
slog.Error("web: list failed", "channel", ch.Slug, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
hasNext := len(rows) > pageSize
if hasNext {
rows = rows[:pageSize]
}
views := make([]StoryView, 0, len(rows))
for _, r := range rows {
views = append(views, toView(r))
}
total, _ := storage.CountClassifiedByChannel(ch.Slug)
base := s.base(r)
base.Active = ch.Slug
data := channelPage{
pageData: base,
Channel: ch,
Stories: views,
Page: page,
HasPrev: page > 1,
HasNext: hasNext,
PrevURL: fmt.Sprintf("/%s?page=%d", ch.Slug, page-1),
NextURL: fmt.Sprintf("/%s?page=%d", ch.Slug, page+1),
Total: total,
}
s.render(w, "channel", data)
}
type bookmarksPage struct {
pageData
Stories []StoryView
Page int
HasPrev bool
HasNext bool
PrevURL string
NextURL string
Total int
}
// handleBookmarks lists the signed-in user's bookmarked stories. Anonymous
// visitors are sent to sign-in (the route is only registered when auth is on).
func (s *Server) handleBookmarks(w http.ResponseWriter, r *http.Request) {
u := s.auth.userFromRequest(r)
if u == nil {
http.Redirect(w, r, "/auth/login?next=/bookmarks", http.StatusSeeOther)
return
}
s.track(r, "bookmarks")
page := 1
if p := r.URL.Query().Get("page"); p != "" {
if n, err := strconv.Atoi(p); err == nil && n > 0 {
page = n
}
}
offset := (page - 1) * pageSize
rows, err := storage.ListBookmarks(u.Sub, pageSize+1, offset)
if err != nil {
slog.Error("web: list bookmarks failed", "sub", u.Sub, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
hasNext := len(rows) > pageSize
if hasNext {
rows = rows[:pageSize]
}
views := make([]StoryView, 0, len(rows))
for _, row := range rows {
views = append(views, toView(row))
}
total, _ := storage.CountBookmarks(u.Sub)
base := s.base(r)
base.Active = "bookmarks"
data := bookmarksPage{
pageData: base,
Stories: views,
Page: page,
HasPrev: page > 1,
HasNext: hasNext,
PrevURL: fmt.Sprintf("/bookmarks?page=%d", page-1),
NextURL: fmt.Sprintf("/bookmarks?page=%d", page+1),
Total: total,
}
s.render(w, "bookmarks", data)
}
var (
demoVariants = []string{"rain", "petals", "jacaranda", "motes", "leaves", "clear", "clouds", "snow", "fog", "storm"}
demoIntensities = []string{"light", "medium", "heavy"}
demoPhases = []string{"day", "dawn", "dusk", "night"}
)
type weatherDemoPage struct {
pageData
Variants []string
Intensities []string
Phases []string
Variant string
Intensity string
PhaseSel string
}
func pickOr(v string, allowed []string, fallback string) string {
for _, a := range allowed {
if v == a {
return v
}
}
return fallback
}
func seasonForVariant(v string) string {
switch v {
case "rain":
return "winter"
case "petals", "jacaranda":
return "spring"
case "motes":
return "summer"
case "leaves":
return "autumn"
}
return ""
}
func (s *Server) handleWeatherDemo(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
variant := pickOr(q.Get("variant"), demoVariants, "jacaranda")
intensity := pickOr(q.Get("intensity"), demoIntensities, "heavy")
phase := pickOr(q.Get("phase"), demoPhases, "day")
base := s.base(r)
base.Weather = Weather{Season: seasonForVariant(variant), Variant: variant, Intensity: intensity}
base.Phase = phase
s.render(w, "weather", weatherDemoPage{
pageData: base,
Variants: demoVariants,
Intensities: demoIntensities,
Phases: demoPhases,
Variant: variant,
Intensity: intensity,
PhaseSel: phase,
})
}
type searchResult struct {
Headline string `json:"headline"`
Lede string `json:"lede,omitempty"`
ArticleURL string `json:"article_url"`
ThumbURL string `json:"thumb_url,omitempty"`
Source string `json:"source"`
Channel string `json:"channel"`
ChannelTitle string `json:"channel_title"`
ChannelTheme string `json:"channel_theme"`
ChannelEmoji string `json:"channel_emoji"`
TimeAgo string `json:"time_ago"`
Posted bool `json:"posted"`
}
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if q == "" {
_, _ = w.Write([]byte(`{"results":[]}`))
return
}
const searchLimit = 12
rows, err := storage.SearchStories(q, searchLimit)
if err != nil {
slog.Error("web: search failed", "q", q, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"results": toSearchResults(rows)})
}
// toSearchResults maps stories to the JSON shape shared by /api/search and
// /api/related, resolving each story's channel to its display metadata.
func toSearchResults(rows []storage.Story) []searchResult {
channelByName := make(map[string]Channel, len(channels))
for _, ch := range channels {
channelByName[ch.Slug] = ch
}
results := make([]searchResult, 0, len(rows))
for _, row := range rows {
ch, ok := channelByName[row.Channel]
if !ok {
ch = Channel{Slug: row.Channel, Title: row.Channel, Theme: row.Channel}
}
results = append(results, searchResult{
Headline: row.Headline,
Lede: row.Lede,
ArticleURL: row.ArticleURL,
ThumbURL: thumbURL(row.ImageURL),
Source: row.Source,
Channel: ch.Slug,
ChannelTitle: ch.Title,
ChannelTheme: ch.Theme,
ChannelEmoji: ch.Emoji,
TimeAgo: shortTimeAgo(time.Unix(row.SeenAt, 0)),
Posted: row.Posted,
})
}
return results
}
// handleRelated returns stories textually similar to a given story, for the
// "You might also like" rail in reader mode. It is public (reader mode works
// for anonymous visitors too); for signed-in users it drops already-read
// stories so recommendations stay fresh.
func (s *Server) handleRelated(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
id, err := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("id")), 10, 64)
if err != nil || id <= 0 {
http.Error(w, `{"error":"bad id"}`, http.StatusBadRequest)
return
}
const relatedLimit = 6
// Over-fetch so dropping already-read stories doesn't starve the rail.
rows, err := storage.RelatedStories(id, relatedLimit*2)
if err != nil {
slog.Error("web: related failed", "id", id, "err", err)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
if s.auth != nil && len(rows) > 0 {
if u := s.auth.userFromRequest(r); u != nil {
ids := make([]int64, 0, len(rows))
for _, row := range rows {
ids = append(ids, row.ID)
}
if read, _, err := storage.UserStoryState(u.Sub, ids); err == nil {
kept := rows[:0]
for _, row := range rows {
if !read[row.ID] {
kept = append(kept, row)
}
}
rows = kept
}
}
}
if len(rows) > relatedLimit {
rows = rows[:relatedLimit]
}
_ = json.NewEncoder(w).Encode(map[string]any{"results": toSearchResults(rows)})
}
// handleArticle serves the stored full text of a single story for reader mode.
// The client already has headline/image/source/time from the card's data
// attributes, so this returns just the body text (and the lede as a fallback
// for stories ingested before content was captured).
func (s *Server) handleArticle(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
id, err := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("id")), 10, 64)
if err != nil || id <= 0 {
http.Error(w, `{"error":"bad id"}`, http.StatusBadRequest)
return
}
content, lede, found, err := storage.GetStoryReaderText(id)
if err != nil {
slog.Error("web: article read failed", "id", id, "err", err)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
if !found {
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"content": content, "lede": lede})
}
func shortTimeAgo(t time.Time) string {
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
case d < 7*24*time.Hour:
return fmt.Sprintf("%dd ago", int(d.Hours())/24)
default:
return t.Format("Jan 2")
}
}
func (s *Server) render(w http.ResponseWriter, page string, data any) {
tpl, ok := s.tpls[page]
if !ok {
http.Error(w, "unknown page", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tpl.ExecuteTemplate(w, "layout", data); err != nil {
slog.Error("web: template render failed", "page", page, "err", err)
}
}
var funcs = template.FuncMap{
"thumb": thumbURL,
"channel": func(slug string) Channel {
for _, ch := range channels {
if ch.Slug == slug {
return ch
}
}
return Channel{Slug: slug, Title: slug, Theme: slug}
},
"dict": func(pairs ...any) map[string]any {
m := make(map[string]any, len(pairs)/2)
for i := 0; i+1 < len(pairs); i += 2 {
key, ok := pairs[i].(string)
if !ok {
continue
}
m[key] = pairs[i+1]
}
return m
},
"timeAgo": func(t time.Time) string {
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
case d < 7*24*time.Hour:
return fmt.Sprintf("%dd ago", int(d.Hours())/24)
default:
return t.Format("Jan 2")
}
},
}