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.
102 lines
3.3 KiB
Go
102 lines
3.3 KiB
Go
package storage
|
|
|
|
import "fmt"
|
|
|
|
// PushSubscription is one browser/device endpoint a signed-in user has opted in
|
|
// for Web Push digests. See the push_subscriptions schema for the field roles.
|
|
type PushSubscription struct {
|
|
Endpoint string
|
|
UserSub string
|
|
P256dh string
|
|
Auth string
|
|
CreatedAt int64
|
|
LastNotifiedAt int64
|
|
}
|
|
|
|
// AddPushSubscription records (or refreshes) a push endpoint for a user. The
|
|
// endpoint is the primary key, so a re-subscribe from the same browser updates
|
|
// the keys and resets the digest watermark to now — the user shouldn't be
|
|
// paged for everything published before they opted in.
|
|
func AddPushSubscription(sub, endpoint, p256dh, auth string) error {
|
|
now := nowUnix()
|
|
_, err := Get().Exec(`
|
|
INSERT INTO push_subscriptions (endpoint, user_sub, p256dh, auth, created_at, last_notified_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(endpoint) DO UPDATE SET
|
|
user_sub = excluded.user_sub,
|
|
p256dh = excluded.p256dh,
|
|
auth = excluded.auth,
|
|
last_notified_at = excluded.last_notified_at`,
|
|
endpoint, sub, p256dh, auth, now, now)
|
|
if err != nil {
|
|
return fmt.Errorf("add push subscription: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RemovePushSubscription drops one endpoint. Used both for an explicit opt-out
|
|
// and when a push service reports the endpoint is gone (404/410).
|
|
func RemovePushSubscription(endpoint string) error {
|
|
_, err := Get().Exec(`DELETE FROM push_subscriptions WHERE endpoint = ?`, endpoint)
|
|
if err != nil {
|
|
return fmt.Errorf("remove push subscription: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListPushSubscriptions returns every stored subscription, for the digest sender.
|
|
func ListPushSubscriptions() ([]PushSubscription, error) {
|
|
rows, err := Get().Query(
|
|
`SELECT endpoint, user_sub, p256dh, auth, created_at, last_notified_at
|
|
FROM push_subscriptions`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []PushSubscription
|
|
for rows.Next() {
|
|
var p PushSubscription
|
|
if err := rows.Scan(&p.Endpoint, &p.UserSub, &p.P256dh, &p.Auth, &p.CreatedAt, &p.LastNotifiedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// TouchPushSubscription advances an endpoint's digest watermark so its next
|
|
// digest only considers stories seen after ts.
|
|
func TouchPushSubscription(endpoint string, ts int64) error {
|
|
_, err := Get().Exec(
|
|
`UPDATE push_subscriptions SET last_notified_at = ? WHERE endpoint = ?`, ts, endpoint)
|
|
if err != nil {
|
|
return fmt.Errorf("touch push subscription: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// NewClassifiedSince returns classified stories first seen after sinceUnix,
|
|
// newest first, capped at limit. The digest sender uses it to count and preview
|
|
// what's new for a subscriber; it carries just the fields a digest needs.
|
|
func NewClassifiedSince(sinceUnix int64, limit int) ([]Story, error) {
|
|
rows, err := Get().Query(
|
|
`SELECT id, headline, source, channel, seen_at
|
|
FROM stories
|
|
WHERE classified = 1 AND channel <> '_discarded' AND seen_at > ?
|
|
ORDER BY seen_at DESC
|
|
LIMIT ?`, sinceUnix, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []Story
|
|
for rows.Next() {
|
|
var s Story
|
|
if err := rows.Scan(&s.ID, &s.Headline, &s.Source, &s.Channel, &s.SeenAt); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
return out, rows.Err()
|
|
}
|