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.
200 lines
5.5 KiB
Go
200 lines
5.5 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"pete/internal/storage"
|
|
|
|
webpush "github.com/SherClockHolmes/webpush-go"
|
|
)
|
|
|
|
// digestScan caps how many new stories the sender inspects per subscriber in one
|
|
// pass. Well past MinStories; the digest only needs a count and one headline.
|
|
const digestScan = 60
|
|
|
|
// runPushSender periodically builds and delivers a "N new stories" digest to
|
|
// each subscriber, respecting their disabled-sources preference. It's started
|
|
// only when push is configured. Best-effort throughout: a failed send never
|
|
// stops the loop, and a permanently-gone endpoint is pruned.
|
|
func (s *Server) runPushSender(ctx context.Context) {
|
|
interval := time.Duration(s.cfg.Push.IntervalMinutes) * time.Minute
|
|
if interval <= 0 {
|
|
interval = 6 * time.Hour
|
|
}
|
|
slog.Info("web: push digest sender started", "interval", interval, "min_stories", s.cfg.Push.MinStories)
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
s.sendDigests()
|
|
}
|
|
}
|
|
}
|
|
|
|
// sendDigests walks every subscription once, notifying those with enough new
|
|
// stories since their last digest.
|
|
func (s *Server) sendDigests() {
|
|
subs, err := storage.ListPushSubscriptions()
|
|
if err != nil {
|
|
slog.Error("push: list subscriptions failed", "err", err)
|
|
return
|
|
}
|
|
if len(subs) == 0 {
|
|
return
|
|
}
|
|
now := time.Now().Unix()
|
|
// Disabled-source sets are per user; cache within a pass so a user with
|
|
// several devices only parses prefs once.
|
|
disabledByUser := make(map[string]map[string]bool)
|
|
sent, pruned := 0, 0
|
|
|
|
for _, sub := range subs {
|
|
stories, err := storage.NewClassifiedSince(sub.LastNotifiedAt, digestScan)
|
|
if err != nil {
|
|
slog.Error("push: scan new stories failed", "sub", sub.UserSub, "err", err)
|
|
continue
|
|
}
|
|
if len(stories) == 0 {
|
|
continue
|
|
}
|
|
disabled, ok := disabledByUser[sub.UserSub]
|
|
if !ok {
|
|
disabled = disabledSourcesFor(sub.UserSub)
|
|
disabledByUser[sub.UserSub] = disabled
|
|
}
|
|
count, top := 0, ""
|
|
for _, st := range stories {
|
|
if disabled[st.Source] {
|
|
continue
|
|
}
|
|
if count == 0 {
|
|
top = st.Headline
|
|
}
|
|
count++
|
|
}
|
|
if count < s.cfg.Push.MinStories {
|
|
continue
|
|
}
|
|
|
|
payload := buildDigestPayload(count, top)
|
|
gone, err := s.sendPush(sub, payload)
|
|
if gone {
|
|
if derr := storage.RemovePushSubscription(sub.Endpoint); derr != nil {
|
|
slog.Error("push: prune gone subscription failed", "err", derr)
|
|
} else {
|
|
pruned++
|
|
}
|
|
continue
|
|
}
|
|
if err != nil {
|
|
slog.Warn("push: send failed", "sub", sub.UserSub, "err", err)
|
|
continue
|
|
}
|
|
if derr := storage.TouchPushSubscription(sub.Endpoint, now); derr != nil {
|
|
slog.Error("push: advance watermark failed", "err", derr)
|
|
}
|
|
sent++
|
|
}
|
|
if sent > 0 || pruned > 0 {
|
|
slog.Info("push: digest pass complete", "sent", sent, "pruned", pruned, "subscriptions", len(subs))
|
|
}
|
|
}
|
|
|
|
// buildDigestPayload renders the notification JSON the service worker expects.
|
|
func buildDigestPayload(count int, top string) []byte {
|
|
body := fmt.Sprintf("%d new stories", count)
|
|
if count == 1 {
|
|
body = "1 new story"
|
|
}
|
|
if top != "" {
|
|
body += " — " + top
|
|
}
|
|
b, _ := json.Marshal(map[string]string{
|
|
"title": "Pete has fresh news",
|
|
"body": body,
|
|
"url": "/",
|
|
"tag": "pete-digest",
|
|
})
|
|
return b
|
|
}
|
|
|
|
// sendPush encrypts and delivers one notification. It reports gone=true when the
|
|
// push service says the endpoint no longer exists (404/410) so the caller can
|
|
// prune it; err is set for other, likely-transient failures.
|
|
func (s *Server) sendPush(sub storage.PushSubscription, payload []byte) (gone bool, err error) {
|
|
resp, err := webpush.SendNotification(payload, &webpush.Subscription{
|
|
Endpoint: sub.Endpoint,
|
|
Keys: webpush.Keys{P256dh: sub.P256dh, Auth: sub.Auth},
|
|
}, &webpush.Options{
|
|
Subscriber: s.cfg.Push.Subject,
|
|
VAPIDPublicKey: s.cfg.Push.VAPIDPublicKey,
|
|
VAPIDPrivateKey: s.cfg.Push.VAPIDPrivateKey,
|
|
TTL: 24 * 60 * 60,
|
|
Urgency: webpush.UrgencyNormal,
|
|
})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode == 404 || resp.StatusCode == 410 {
|
|
return true, nil
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
return false, fmt.Errorf("push service returned %d", resp.StatusCode)
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// disabledSourcesFor returns the set of source names a user has hidden, read
|
|
// from their stored prefs blob. The blob mirrors localStorage: a JSON object
|
|
// whose "pete.disabledSources.v1" value is itself a JSON string encoding a
|
|
// {sourceName: true} map. Any parse failure yields an empty (deny-nothing) set.
|
|
func disabledSourcesFor(sub string) map[string]bool {
|
|
out := map[string]bool{}
|
|
blob, err := storage.GetUserPrefs(sub)
|
|
if err != nil || blob == "" {
|
|
return out
|
|
}
|
|
var prefs map[string]json.RawMessage
|
|
if err := json.Unmarshal([]byte(blob), &prefs); err != nil {
|
|
return out
|
|
}
|
|
raw, ok := prefs["pete.disabledSources.v1"]
|
|
if !ok {
|
|
return out
|
|
}
|
|
// The value is normally a JSON *string* containing JSON; unwrap that first,
|
|
// but tolerate a bare object too.
|
|
inner := []byte(raw)
|
|
var asStr string
|
|
if err := json.Unmarshal(raw, &asStr); err == nil {
|
|
inner = []byte(asStr)
|
|
}
|
|
var set map[string]bool
|
|
if err := json.Unmarshal(inner, &set); err != nil {
|
|
return out
|
|
}
|
|
for name, on := range set {
|
|
if on {
|
|
out[name] = true
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// StartPushSender launches the digest loop if push is enabled. Safe to call
|
|
// unconditionally; it's a no-op when push is off.
|
|
func (s *Server) StartPushSender(ctx context.Context) {
|
|
if !s.cfg.Push.Enabled || s.auth == nil {
|
|
return
|
|
}
|
|
go s.runPushSender(ctx)
|
|
}
|