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:
@@ -27,6 +27,30 @@ type WebConfig struct {
|
||||
SiteTitle string `toml:"site_title"` // display name in the header
|
||||
BaseURL string `toml:"base_url"` // public URL (used in metadata only)
|
||||
Auth AuthConfig `toml:"auth"` // optional OIDC sign-in (Authentik)
|
||||
Push PushConfig `toml:"push"` // optional Web Push digests (VAPID)
|
||||
// AdminSubs is the allowlist of OIDC subjects allowed to view the
|
||||
// owner-facing source-health dashboard at /status. Empty means the page is
|
||||
// inaccessible to everyone (returns 404). Requires auth to be enabled.
|
||||
AdminSubs []string `toml:"admin_subs"`
|
||||
}
|
||||
|
||||
// PushConfig wires the Web Push digest sender. Push is signed-in only (it keys
|
||||
// off the OIDC subject) so it does nothing unless auth is also enabled. Generate
|
||||
// a VAPID keypair once with `pete -genvapid` and paste the two keys here.
|
||||
type PushConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
VAPIDPublicKey string `toml:"vapid_public_key"`
|
||||
VAPIDPrivateKey string `toml:"vapid_private_key"`
|
||||
// Subject identifies the sender to the push service; a mailto: or https: URL
|
||||
// per RFC 8292. Defaults to mailto:admin@<base_url host> is not attempted —
|
||||
// set it explicitly.
|
||||
Subject string `toml:"subject"`
|
||||
// IntervalMinutes is how often the digest sender wakes to look for new
|
||||
// stories per subscriber. Defaults to 360 (6h).
|
||||
IntervalMinutes int `toml:"interval_minutes"`
|
||||
// MinStories is the smallest number of new stories that triggers a digest
|
||||
// for a subscriber, so they aren't pinged for a single item. Defaults to 3.
|
||||
MinStories int `toml:"min_stories"`
|
||||
}
|
||||
|
||||
// AuthConfig wires Pete's web UI to an OIDC provider (our Authentik instance).
|
||||
@@ -167,6 +191,19 @@ func (c *Config) validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
if c.Web.Push.Enabled {
|
||||
if !c.Web.Auth.Enabled {
|
||||
return fmt.Errorf("web.push requires web.auth to be enabled (push is signed-in only)")
|
||||
}
|
||||
p := c.Web.Push
|
||||
if p.VAPIDPublicKey == "" || p.VAPIDPrivateKey == "" {
|
||||
return fmt.Errorf("web.push requires vapid_public_key and vapid_private_key (generate with: pete -genvapid)")
|
||||
}
|
||||
if p.Subject == "" {
|
||||
return fmt.Errorf("web.push.subject is required (a mailto: or https: URL identifying the sender)")
|
||||
}
|
||||
}
|
||||
|
||||
for i, s := range c.Sources {
|
||||
if s.Name == "" {
|
||||
return fmt.Errorf("sources[%d].name is required", i)
|
||||
@@ -232,6 +269,12 @@ func (c *Config) applyDefaults() {
|
||||
if c.Web.SiteTitle == "" {
|
||||
c.Web.SiteTitle = "Pete"
|
||||
}
|
||||
if c.Web.Push.IntervalMinutes == 0 {
|
||||
c.Web.Push.IntervalMinutes = 360
|
||||
}
|
||||
if c.Web.Push.MinStories == 0 {
|
||||
c.Web.Push.MinStories = 3
|
||||
}
|
||||
for i := range c.Sources {
|
||||
if c.Sources[i].PollIntervalMinutes == 0 {
|
||||
c.Sources[i].PollIntervalMinutes = 20
|
||||
|
||||
@@ -211,6 +211,43 @@ feed_url = "https://e.com/rss"
|
||||
tier = 1
|
||||
poll_interval_minutes = 20
|
||||
enabled = true
|
||||
`},
|
||||
{"push enabled without auth", `
|
||||
[matrix]
|
||||
homeserver = "https://h"
|
||||
user_id = "@p:h"
|
||||
password = "pw"
|
||||
pickle_key = "test_pickle_key_1234"
|
||||
[matrix.channels]
|
||||
tech = "!t:e"
|
||||
[storage]
|
||||
db_path = "/tmp/t.db"
|
||||
[web.push]
|
||||
enabled = true
|
||||
vapid_public_key = "pub"
|
||||
vapid_private_key = "priv"
|
||||
subject = "mailto:a@b.c"
|
||||
`},
|
||||
{"push enabled missing keys", `
|
||||
[matrix]
|
||||
homeserver = "https://h"
|
||||
user_id = "@p:h"
|
||||
password = "pw"
|
||||
pickle_key = "test_pickle_key_1234"
|
||||
[matrix.channels]
|
||||
tech = "!t:e"
|
||||
[storage]
|
||||
db_path = "/tmp/t.db"
|
||||
[web.auth]
|
||||
enabled = true
|
||||
issuer = "https://i"
|
||||
client_id = "id"
|
||||
client_secret = "secret"
|
||||
redirect_url = "https://r/cb"
|
||||
session_secret = "session_secret_16chars_long"
|
||||
[web.push]
|
||||
enabled = true
|
||||
subject = "mailto:a@b.c"
|
||||
`},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user