- push digest queries now exclude _duplicate channel like every other
visibility query (bookmarks list/count and NewClassifiedSince)
- advance push watermark to newest scanned story, not pass-start now, so
stories arriving during a long send pass aren't re-counted next pass
- replace hand-rolled escapeHTMLText with stdlib html.EscapeString
- drop em-dashes from user-facing copy; bump PWA CACHE_VERSION so clients
pick up the changed shell assets
Code review of the personalization/feeds/PWA/push work surfaced ten
confirmed issues, now fixed:
- Web Push delivery bypassed the SSRF guard (unguarded default client);
now routes through safehttp.NewClient with a hard timeout, and the
subscribe handler validates the endpoint URL.
- Push unsubscribe deleted by endpoint with no owner check; added
RemovePushSubscriptionForUser scoped to the signed-in user.
- Byte-slice body/content truncation could split a UTF-8 rune and break
the RSS content:encoded XML; added a rune-safe truncateUTF8 helper.
- Digest sender could permanently starve a user who hid a high-volume
source; step the watermark past a full hidden-source scan window.
- Service worker cached personalized HTML navigations into a shared
cache (identity leak across PWA users); navigations are now
network-only, CACHE_VERSION bumped to v2 to purge stale pages.
- Public /api/article leaked discarded/unclassified bodies; filter to
classified, non-sentinel stories.
- runLocal never started the push sender; digests now fire in -local.
- Push client had no timeout, so one hung endpoint stalled all sends.
- Reader migration resurrected cross-device-cleared reads; gate it
behind a one-time flag so the server stays authoritative.
- Bookmarks count didn't match the classified list filter.
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.
Reader mode presents the stories on a page one at a time in a focused
overlay, marking each read as it's shown. Left/right arrows (or the header
book button / `f`) page through them; read stories dim on the grid. Read
state is device-local in localStorage.
Backing this required actually capturing article bodies, which Pete wasn't
doing — it kept only the RSS <description> lede and discarded content:encoded:
- stories.content column (idempotent migration; old rows fall back to lede)
- parser keeps content:encoded as paragraph-preserving text
- article fetch already done for paywall detection now also returns its body,
so ingest stores the richer of feed-content vs scraped body with no extra
request (prefers the archive snapshot body for paywalled stories)
- GET /api/article?id= serves the stored text; card queries now select id and
expose it as data-id for the reader
Tests cover content extraction, the storage round-trip, and the article
endpoint + card rendering end to end.
Reuses the saved weather location's lat/lon to fetch us_aqi from
Open-Meteo's air-quality API (no key), folded into the existing 2h
forecast cache (bumped v1->v2). AQI is best-effort: a failed fetch
never sinks the forecast. Shows as a header chip and a colored row
on the forecast card; both self-hide when there is no reading.
Signed-in users get their preferences (hidden feeds, weather location,
weather toggle) stored server-side keyed by their OIDC subject and synced
across devices. Anonymous visitors keep using browser localStorage, so the
site stays public. First sign-in migrates existing localStorage prefs up.
- config: [web.auth] section (issuer, client_id/secret, redirect, session_secret)
- storage: user_preferences table + Get/PutUserPrefs
- web/auth: OIDC code flow, HMAC-signed session cookie, CSRF state + nonce
- web/prefs_api: GET/PUT /api/preferences (auth-gated, 64KB cap)
- frontend: prefs.js sync layer seeds localStorage from server, pushes on write
- header: sign-in / account control
OIDC discovery is non-fatal at boot: if Authentik is down, Pete serves
anonymously rather than refusing to start.
Visitors can save a postal code (international, via Zippopotam) to get the
local forecast from Open-Meteo — a header chip + a 5-day home-page card —
and the canvas background switches from the seasonal effect to live
conditions. Entirely client-side: no keys, no server logic. Geocode cached
permanently, forecast cached 2h. Celsius by default, Fahrenheit opt-in.
New canvas effects: clear (sun by day, shaded moon + stars at night),
clouds (blurred drifting sprites), snow, fog, and storm (rain + lightning).
Seasonal effects remain the no-location fallback.
btoa() throws InvalidCharacterError on non-Latin1 input, so feed
names with em-dashes ("The Guardian — World") killed render()
before the dialog's hidden class was removed and nothing visibly
happened on click. Use the loop index for the row id instead.
Visitors can hide individual feeds via a gear-icon panel in the header.
Preferences live in localStorage; the server ships the full source list
(name + channel) as window.PETE_SOURCES so the panel lists every feed,
grouped by channel, regardless of what's on the current page.
- Add internal/safehttp: hardened HTTP client (DNS-resolved dial guard
blocking loopback/RFC1918/CGNAT/link-local, redirect re-validation,
body-size cap) and rewire article/feed/thumb clients through it
- Cap goquery body at 5 MiB so a hostile origin can't OOM the process
- search.js: reject non-http(s) hrefs to block stored XSS via javascript:
- dedup: tracking-param key "CMP" was unreachable (lookup lowercases);
fixed to "cmp" so CMP= is actually stripped from canonical URLs
- ForcePost: postItem now returns bool; on dedup-skip ForcePost returns
false so !post falls back to DB lookup instead of silently consuming
- Bound reaction callbacks behind an 8-slot semaphore; drop overflow
- Add stories indexes on (channel, classified, seen_at DESC),
(classified, seen_at DESC), and partial image_url to kill full scans
in IsKnownImageURL and ORDER BY seen_at hot paths
- Surface FTS5 probe Scan error instead of swallowing it
Search: new FTS5-backed SearchStories query, /search JSON endpoint,
client-side overlay (search.js) wired into the layout header. EU
channel gets its own theme color (#003399) across bg/text/border/glow
classes. Sources can now route to non-Matrix channels without
validation error (web-only mode); a warning still flags typos.
Server picks variant from Lisbon-local date (rain/petals/jacaranda/motes/leaves)
and renders behind content via a canvas particle layer. Each variant has a
hand-drawn silhouette so shapes are recognizable. /weather demo route exposes
variant + intensity + phase pickers, locking the time-of-day phase override.