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.
This commit is contained in:
199
internal/web/push_sender.go
Normal file
199
internal/web/push_sender.go
Normal file
@@ -0,0 +1,199 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user