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:
@@ -37,6 +37,12 @@ listen_addr = ":8080"
|
||||
site_title = "Pete"
|
||||
base_url = "https://news.parodia.dev"
|
||||
|
||||
# OIDC subjects allowed to view the owner-facing source-health dashboard at
|
||||
# /status (per-feed poll status, failures, content stats). Requires web.auth
|
||||
# below. Empty = /status returns 404 for everyone. Find a user's subject in the
|
||||
# server logs ("auth: user signed in" sub=...) after they sign in once.
|
||||
admin_subs = []
|
||||
|
||||
# Optional OIDC sign-in (Authentik). When enabled, signed-in users get their
|
||||
# preferences (hidden feeds, weather location, toggles) stored server-side keyed
|
||||
# by their OIDC subject and synced across devices. Anonymous visitors keep using
|
||||
@@ -53,6 +59,24 @@ redirect_url = "https://news.parodia.dev/auth/callback"
|
||||
# HMAC key that signs the session cookie. Generate with: openssl rand -hex 32
|
||||
session_secret = "${PETE_SESSION_SECRET}"
|
||||
|
||||
# Optional Web Push digests. When enabled, signed-in users can opt in (from the
|
||||
# feed-settings panel) to a periodic "N new stories" notification, delivered via
|
||||
# a service worker so it also works when the site is installed as a PWA. Push is
|
||||
# signed-in only, so it needs web.auth above; it does nothing otherwise.
|
||||
# Generate the VAPID keypair once with: pete -genvapid
|
||||
# then paste the two keys here (the private key is a secret — treat it like a
|
||||
# password). subject identifies you to the push service (a mailto: or https: URL).
|
||||
[web.push]
|
||||
enabled = false
|
||||
vapid_public_key = "${PETE_VAPID_PUBLIC_KEY}"
|
||||
vapid_private_key = "${PETE_VAPID_PRIVATE_KEY}"
|
||||
subject = "mailto:admin@parodia.dev"
|
||||
# How often the sender wakes to look for new stories per subscriber (minutes).
|
||||
interval_minutes = 360
|
||||
# Smallest number of new (non-hidden) stories that triggers a digest, so a lone
|
||||
# item doesn't ping everyone.
|
||||
min_stories = 3
|
||||
|
||||
# Every enabled source MUST set direct_route to a key from [matrix.channels] above.
|
||||
# There is no automatic classification — Pete posts each story to its configured channel.
|
||||
# Optional: language = "en" drops feed items whose per-item <language> tag is
|
||||
|
||||
10
go.mod
10
go.mod
@@ -5,7 +5,12 @@ go 1.25.0
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.6.0
|
||||
github.com/PuerkitoBio/goquery v1.12.0
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||
github.com/coreos/go-oidc/v3 v3.19.0
|
||||
github.com/mmcdole/gofeed v1.3.0
|
||||
go.mau.fi/util v0.9.9
|
||||
golang.org/x/image v0.41.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
maunium.net/go/mautrix v0.28.0
|
||||
modernc.org/sqlite v1.50.1
|
||||
)
|
||||
@@ -13,9 +18,9 @@ require (
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/coreos/go-oidc/v3 v3.19.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
@@ -32,12 +37,9 @@ require (
|
||||
github.com/tidwall/match v1.2.0 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
go.mau.fi/util v0.9.9 // indirect
|
||||
golang.org/x/crypto v0.52.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect
|
||||
golang.org/x/image v0.41.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
|
||||
4
go.sum
4
go.sum
@@ -6,6 +6,8 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7Oputl
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
|
||||
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/coreos/go-oidc/v3 v3.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I=
|
||||
@@ -17,6 +19,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
|
||||
@@ -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"
|
||||
`},
|
||||
}
|
||||
|
||||
|
||||
@@ -97,8 +97,16 @@ func (p *Poller) pollOnce(ctx context.Context, src config.SourceConfig) {
|
||||
func (p *Poller) pollOnceWithErr(ctx context.Context, src config.SourceConfig) error {
|
||||
items, err := FetchFeed(src.FeedURL, src.UserAgent)
|
||||
if err != nil {
|
||||
// Persist the failure for the owner-facing source-health dashboard. The
|
||||
// in-memory consecutiveFailures in pollSource drives log warnings; this
|
||||
// row is the authoritative record the dashboard reads.
|
||||
storage.RecordPollResult(src.Name, false, 0, err)
|
||||
return err
|
||||
}
|
||||
// A successful fetch: record health now (before per-item processing) with
|
||||
// the number of items the feed returned, so the dashboard reflects feed
|
||||
// reachability even if individual items are later dropped as duplicates.
|
||||
storage.RecordPollResult(src.Name, true, len(items), nil)
|
||||
|
||||
newCount := 0
|
||||
for i := range items {
|
||||
|
||||
@@ -120,6 +120,11 @@ func RunMaintenance() {
|
||||
exec("prune old reactions",
|
||||
`DELETE FROM reactions WHERE reacted_at < ?`, storyCutoff)
|
||||
|
||||
// Drop per-user read/bookmark rows whose story has been pruned above, so the
|
||||
// table can't accumulate dangling references as stories age out.
|
||||
exec("prune orphan user_story_state",
|
||||
`DELETE FROM user_story_state WHERE story_id NOT IN (SELECT id FROM stories)`)
|
||||
|
||||
// Daily unique tokens are only useful for the recent window; their salts are
|
||||
// long gone. page_views is kept forever (tiny aggregate, all-time totals).
|
||||
exec("prune old daily_visitors",
|
||||
|
||||
101
internal/storage/push.go
Normal file
101
internal/storage/push.go
Normal file
@@ -0,0 +1,101 @@
|
||||
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()
|
||||
}
|
||||
105
internal/storage/push_test.go
Normal file
105
internal/storage/push_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package storage
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPushSubscriptionLifecycle(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
if err := AddPushSubscription("sub-1", "https://push.example/ep-a", "p256-a", "auth-a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A second endpoint for the same user (e.g. a second device).
|
||||
if err := AddPushSubscription("sub-1", "https://push.example/ep-b", "p256-b", "auth-b"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A different user.
|
||||
if err := AddPushSubscription("sub-2", "https://push.example/ep-c", "p256-c", "auth-c"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
subs, err := ListPushSubscriptions()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(subs) != 3 {
|
||||
t.Fatalf("got %d subscriptions, want 3", len(subs))
|
||||
}
|
||||
|
||||
// Re-subscribing the same endpoint updates keys in place, not a new row.
|
||||
if err := AddPushSubscription("sub-1", "https://push.example/ep-a", "p256-a2", "auth-a2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subs, _ = ListPushSubscriptions()
|
||||
if len(subs) != 3 {
|
||||
t.Fatalf("after re-subscribe got %d rows, want 3 (upsert, not insert)", len(subs))
|
||||
}
|
||||
var epA PushSubscription
|
||||
for _, s := range subs {
|
||||
if s.Endpoint == "https://push.example/ep-a" {
|
||||
epA = s
|
||||
}
|
||||
}
|
||||
if epA.P256dh != "p256-a2" || epA.Auth != "auth-a2" {
|
||||
t.Errorf("re-subscribe did not refresh keys: %+v", epA)
|
||||
}
|
||||
|
||||
if err := RemovePushSubscription("https://push.example/ep-a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subs, _ = ListPushSubscriptions()
|
||||
if len(subs) != 2 {
|
||||
t.Fatalf("after remove got %d rows, want 2", len(subs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTouchPushSubscriptionAdvancesWatermark(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
if err := AddPushSubscription("sub-1", "https://push.example/ep", "p", "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subs, _ := ListPushSubscriptions()
|
||||
orig := subs[0].LastNotifiedAt
|
||||
|
||||
want := orig + 5000
|
||||
if err := TouchPushSubscription("https://push.example/ep", want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subs, _ = ListPushSubscriptions()
|
||||
if subs[0].LastNotifiedAt != want {
|
||||
t.Errorf("watermark = %d, want %d", subs[0].LastNotifiedAt, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClassifiedSince(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
now := nowUnix()
|
||||
|
||||
insert := func(guid, headline, source, channel string, classified bool, seenAt int64) {
|
||||
if err := InsertStory(&Story{
|
||||
GUID: guid, Headline: headline, ArticleURL: "https://x/" + guid,
|
||||
Source: source, Channel: channel, Classified: classified, SeenAt: seenAt,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
insert("old", "Old news", "Feed A", "tech", true, now-1000)
|
||||
insert("fresh1", "Fresh one", "Feed A", "tech", true, now-100)
|
||||
insert("fresh2", "Fresh two", "Feed B", "gaming", true, now-50)
|
||||
insert("unclassified", "Pending", "Feed A", "", false, now-10)
|
||||
insert("discarded", "Junk", "Feed A", "_discarded", true, now-10)
|
||||
|
||||
got, err := NewClassifiedSince(now-500, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d stories, want 2 (fresh classified, non-discarded, after watermark)", len(got))
|
||||
}
|
||||
// Newest first.
|
||||
if got[0].GUID != "" && got[0].Headline != "Fresh two" {
|
||||
t.Errorf("first result = %q, want newest 'Fresh two'", got[0].Headline)
|
||||
}
|
||||
if got[0].Source != "Feed B" || got[1].Source != "Feed A" {
|
||||
t.Errorf("unexpected order/sources: %q then %q", got[0].Source, got[1].Source)
|
||||
}
|
||||
}
|
||||
@@ -300,6 +300,55 @@ func ListAllClassified(limit, offset int) ([]Story, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListForFeed returns up to `limit` classified stories for the outbound RSS and
|
||||
// JSON feeds, newest first. Unlike the web list queries it also selects the full
|
||||
// `content` and `published_at`, which the feeds need for content:encoded bodies
|
||||
// and real pubDates. channel == "" spans all real channels; otherwise it scopes
|
||||
// to that one channel. Sentinel channels are always excluded.
|
||||
func ListForFeed(channel string, limit int) ([]Story, error) {
|
||||
const cols = `s.id, s.guid, s.headline, s.lede, s.content, s.image_url, s.article_url, s.url_canonical, s.source, s.channel, s.seen_at, s.published_at`
|
||||
var (
|
||||
rows *sql.Rows
|
||||
err error
|
||||
)
|
||||
if channel == "" {
|
||||
rows, err = Get().Query(
|
||||
`SELECT `+cols+`
|
||||
FROM stories s
|
||||
WHERE s.classified = 1
|
||||
AND s.channel IS NOT NULL
|
||||
AND s.channel NOT IN ('_discarded', '_duplicate')
|
||||
ORDER BY COALESCE(s.published_at, s.seen_at) DESC
|
||||
LIMIT ?`, limit)
|
||||
} else {
|
||||
rows, err = Get().Query(
|
||||
`SELECT `+cols+`
|
||||
FROM stories s
|
||||
WHERE s.classified = 1
|
||||
AND s.channel = ?
|
||||
ORDER BY COALESCE(s.published_at, s.seen_at) DESC
|
||||
LIMIT ?`, channel, limit)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Story
|
||||
for rows.Next() {
|
||||
var s Story
|
||||
var content, canonical sql.NullString
|
||||
var published sql.NullInt64
|
||||
if err := rows.Scan(&s.ID, &s.GUID, &s.Headline, &s.Lede, &content, &s.ImageURL, &s.ArticleURL, &canonical, &s.Source, &s.Channel, &s.SeenAt, &published); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Content = content.String
|
||||
s.URLCanonical = canonical.String
|
||||
s.PublishedAt = published.Int64
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListRecentlyPosted returns up to `limit` stories that have been posted to
|
||||
// Matrix, ordered by post time (newest first). Posted is always true.
|
||||
func ListRecentlyPosted(limit int) ([]Story, error) {
|
||||
|
||||
275
internal/storage/rank.go
Normal file
275
internal/storage/rank.go
Normal file
@@ -0,0 +1,275 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Ranking weights for the "For you" feed. Bookmarks are a stronger signal of
|
||||
// interest than a plain read, and channel affinity outweighs source affinity
|
||||
// (a reader follows topics more than outlets). Recency keeps the feed fresh so
|
||||
// a heavily-read channel doesn't surface week-old stories over today's news.
|
||||
const (
|
||||
affinityReadWeight = 1.0
|
||||
affinityBookmarkWeight = 3.0
|
||||
|
||||
forYouChannelWeight = 1.0
|
||||
forYouSourceWeight = 0.7
|
||||
forYouRecencyWeight = 0.9
|
||||
|
||||
// forYouCandidatePool bounds how many recent unread stories we score in Go.
|
||||
forYouCandidatePool = 400
|
||||
// forYouRecencyHalfLifeHours sets how fast the recency term decays.
|
||||
forYouRecencyHalfLifeHours = 48.0
|
||||
)
|
||||
|
||||
// userAffinity builds normalized channel and source affinity scores (each 0..1)
|
||||
// for a signed-in user from their read + bookmark history. Bookmarks count for
|
||||
// more than plain reads. total is the number of signal-bearing rows; when it is
|
||||
// zero the user has no history yet and both maps are empty.
|
||||
func userAffinity(sub string) (channel, source map[string]float64, total int, err error) {
|
||||
channel = make(map[string]float64)
|
||||
source = make(map[string]float64)
|
||||
if sub == "" {
|
||||
return channel, source, 0, nil
|
||||
}
|
||||
rows, err := Get().Query(
|
||||
`SELECT s.channel, s.source, u.read_at, u.bookmarked_at
|
||||
FROM user_story_state u
|
||||
JOIN stories s ON s.id = u.story_id
|
||||
WHERE u.user_sub = ?
|
||||
AND s.channel IS NOT NULL
|
||||
AND s.channel NOT IN ('_discarded', '_duplicate')`, sub)
|
||||
if err != nil {
|
||||
return nil, nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var ch, src string
|
||||
var readAt, markAt sql.NullInt64
|
||||
if err := rows.Scan(&ch, &src, &readAt, &markAt); err != nil {
|
||||
return nil, nil, 0, err
|
||||
}
|
||||
w := 0.0
|
||||
if readAt.Valid {
|
||||
w += affinityReadWeight
|
||||
}
|
||||
if markAt.Valid {
|
||||
w += affinityBookmarkWeight
|
||||
}
|
||||
if w == 0 {
|
||||
continue
|
||||
}
|
||||
channel[ch] += w
|
||||
source[src] += w
|
||||
total++
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, nil, 0, err
|
||||
}
|
||||
normalizeMax(channel)
|
||||
normalizeMax(source)
|
||||
return channel, source, total, nil
|
||||
}
|
||||
|
||||
// normalizeMax scales a map's values so the largest becomes 1.0, leaving an
|
||||
// all-zero (or empty) map untouched.
|
||||
func normalizeMax(m map[string]float64) {
|
||||
var max float64
|
||||
for _, v := range m {
|
||||
if v > max {
|
||||
max = v
|
||||
}
|
||||
}
|
||||
if max == 0 {
|
||||
return
|
||||
}
|
||||
for k := range m {
|
||||
m[k] /= max
|
||||
}
|
||||
}
|
||||
|
||||
// ForYou returns a personalized ranking of recent, unread stories for a
|
||||
// signed-in user, blending channel affinity, source affinity, and recency. It
|
||||
// returns (nil, nil) when the user has no read/bookmark history yet, so callers
|
||||
// can fall back to the plain latest feed.
|
||||
func ForYou(sub string, limit int) ([]Story, error) {
|
||||
if limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
chAff, srcAff, total, err := userAffinity(sub)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if total == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := Get().Query(
|
||||
`SELECT s.id, s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at, COALESCE(s.published_at, s.seen_at),
|
||||
EXISTS(SELECT 1 FROM post_log p WHERE p.guid = s.guid) AS posted
|
||||
FROM stories s
|
||||
WHERE s.classified = 1
|
||||
AND s.channel IS NOT NULL
|
||||
AND s.channel NOT IN ('_discarded', '_duplicate')
|
||||
AND s.id NOT IN (
|
||||
SELECT story_id FROM user_story_state
|
||||
WHERE user_sub = ? AND read_at IS NOT NULL)
|
||||
ORDER BY COALESCE(s.published_at, s.seen_at) DESC
|
||||
LIMIT ?`, sub, forYouCandidatePool)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
now := float64(nowUnix())
|
||||
type scored struct {
|
||||
s Story
|
||||
score float64
|
||||
}
|
||||
var cands []scored
|
||||
for rows.Next() {
|
||||
var s Story
|
||||
var effectiveAt int64
|
||||
if err := rows.Scan(&s.ID, &s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.Paywalled, &s.SeenAt, &effectiveAt, &s.Posted); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ageHours := (now - float64(effectiveAt)) / 3600.0
|
||||
if ageHours < 0 {
|
||||
ageHours = 0
|
||||
}
|
||||
recency := math.Exp(-ageHours / forYouRecencyHalfLifeHours)
|
||||
score := forYouChannelWeight*chAff[s.Channel] +
|
||||
forYouSourceWeight*srcAff[s.Source] +
|
||||
forYouRecencyWeight*recency
|
||||
cands = append(cands, scored{s, score})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Candidates arrive newest-first, and a stable sort keeps that order for
|
||||
// equal scores, so ties break toward the more recent story.
|
||||
sort.SliceStable(cands, func(i, j int) bool { return cands[i].score > cands[j].score })
|
||||
if len(cands) > limit {
|
||||
cands = cands[:limit]
|
||||
}
|
||||
out := make([]Story, 0, len(cands))
|
||||
for _, c := range cands {
|
||||
out = append(out, c.s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// relatedRecencyWindowSeconds bounds "related" results to roughly the same
|
||||
// window the story feed lives in (stories are pruned at 30 days anyway).
|
||||
const relatedRecencyWindowSeconds = 30 * 86400
|
||||
|
||||
// relatedStopwords are high-frequency, low-signal tokens dropped when building
|
||||
// the "related" FTS query so matches key off the story's actual subject matter.
|
||||
var relatedStopwords = map[string]bool{
|
||||
"the": true, "and": true, "for": true, "with": true, "that": true,
|
||||
"this": true, "from": true, "has": true, "have": true, "are": true,
|
||||
"was": true, "were": true, "will": true, "its": true, "into": true,
|
||||
"out": true, "how": true, "why": true, "what": true, "who": true,
|
||||
"you": true, "your": true, "not": true, "but": true, "all": true,
|
||||
"new": true, "now": true, "more": true, "after": true, "over": true,
|
||||
"about": true, "says": true, "said": true, "of": true, "to": true,
|
||||
"in": true, "is": true, "on": true, "at": true, "by": true, "as": true,
|
||||
"an": true, "be": true, "it": true, "or": true, "if": true, "we": true,
|
||||
"he": true, "do": true, "up": true, "so": true, "no": true, "my": true,
|
||||
}
|
||||
|
||||
// RelatedStories returns classified stories textually similar to the given
|
||||
// story, ranked by FTS5 relevance and excluding the story itself. The match
|
||||
// query is an OR of significant tokens from the seed story's headline and lede,
|
||||
// so it surfaces stories that share subject matter rather than requiring every
|
||||
// term (which is what SearchStories' implicit-AND query would demand).
|
||||
func RelatedStories(storyID int64, limit int) ([]Story, error) {
|
||||
if storyID <= 0 || limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var headline, lede sql.NullString
|
||||
err := Get().QueryRow(`SELECT headline, lede FROM stories WHERE id = ?`, storyID).Scan(&headline, &lede)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
fts := buildRelatedFTSQuery(headline.String + " " + lede.String)
|
||||
if fts == "" {
|
||||
return nil, nil
|
||||
}
|
||||
cutoff := nowUnix() - relatedRecencyWindowSeconds
|
||||
rows, err := Get().Query(
|
||||
`SELECT s.id, s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at,
|
||||
EXISTS(SELECT 1 FROM post_log p WHERE p.guid = s.guid) AS posted
|
||||
FROM stories_fts f
|
||||
JOIN stories s ON s.id = f.rowid
|
||||
WHERE f.stories_fts MATCH ?
|
||||
AND s.id != ?
|
||||
AND s.classified = 1
|
||||
AND s.channel IS NOT NULL
|
||||
AND s.channel NOT IN ('_discarded', '_duplicate')
|
||||
AND s.seen_at >= ?
|
||||
ORDER BY bm25(stories_fts) ASC, s.seen_at DESC
|
||||
LIMIT ?`, fts, storyID, cutoff, 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.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.Paywalled, &s.SeenAt, &s.Posted); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// buildRelatedFTSQuery tokenizes a story's headline+lede into a de-duplicated,
|
||||
// stopword-filtered OR query of quoted terms, capped so a long lede can't build
|
||||
// a pathological query. Returns "" when nothing usable remains.
|
||||
func buildRelatedFTSQuery(raw string) string {
|
||||
var tokens []string
|
||||
var cur strings.Builder
|
||||
seen := make(map[string]bool)
|
||||
flush := func() {
|
||||
if cur.Len() == 0 {
|
||||
return
|
||||
}
|
||||
t := strings.ToLower(cur.String())
|
||||
cur.Reset()
|
||||
if len([]rune(t)) < 2 || relatedStopwords[t] || seen[t] {
|
||||
return
|
||||
}
|
||||
seen[t] = true
|
||||
tokens = append(tokens, t)
|
||||
}
|
||||
for _, r := range raw {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
||||
cur.WriteRune(r)
|
||||
case r > 127:
|
||||
cur.WriteRune(r)
|
||||
default:
|
||||
flush()
|
||||
}
|
||||
}
|
||||
flush()
|
||||
if len(tokens) == 0 {
|
||||
return ""
|
||||
}
|
||||
const maxTokens = 16
|
||||
if len(tokens) > maxTokens {
|
||||
tokens = tokens[:maxTokens]
|
||||
}
|
||||
for i, t := range tokens {
|
||||
tokens[i] = `"` + t + `"`
|
||||
}
|
||||
return strings.Join(tokens, " OR ")
|
||||
}
|
||||
141
internal/storage/rank_test.go
Normal file
141
internal/storage/rank_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// seedStoryFull inserts a story with an explicit channel + source so ranking
|
||||
// tests can build a skewed affinity profile.
|
||||
func seedStoryFull(t *testing.T, guid, channel, source, headline, lede string) int64 {
|
||||
t.Helper()
|
||||
s := &Story{
|
||||
GUID: guid,
|
||||
Headline: headline,
|
||||
Lede: lede,
|
||||
ArticleURL: "https://example.com/" + guid,
|
||||
Source: source,
|
||||
Channel: channel,
|
||||
Classified: true,
|
||||
SeenAt: nowUnix(),
|
||||
}
|
||||
if err := InsertStory(s); err != nil {
|
||||
t.Fatalf("insert story %s: %v", guid, err)
|
||||
}
|
||||
var id int64
|
||||
if err := Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, guid).Scan(&id); err != nil {
|
||||
t.Fatalf("lookup id %s: %v", guid, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestForYou_LeansToAffinityAndExcludesRead(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
const sub = "sub-1"
|
||||
|
||||
// A skewed history: the user reads/bookmarks gaming stories.
|
||||
g1 := seedStoryFull(t, "g1", "gaming", "GameSite", "Game one", "")
|
||||
g2 := seedStoryFull(t, "g2", "gaming", "GameSite", "Game two", "")
|
||||
// Candidates to rank (all unread until we mark some).
|
||||
gc := seedStoryFull(t, "gc", "gaming", "GameSite", "Fresh gaming story", "")
|
||||
tc := seedStoryFull(t, "tc", "tech", "TechSite", "Fresh tech story", "")
|
||||
|
||||
if err := SetRead(sub, g1, true); err != nil {
|
||||
t.Fatalf("read g1: %v", err)
|
||||
}
|
||||
if err := SetBookmark(sub, g2, true); err != nil {
|
||||
t.Fatalf("bookmark g2: %v", err)
|
||||
}
|
||||
|
||||
got, err := ForYou(sub, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ForYou: %v", err)
|
||||
}
|
||||
if len(got) == 0 {
|
||||
t.Fatal("expected results")
|
||||
}
|
||||
// Affinity leans gaming, so a gaming story tops the list and the tech story
|
||||
// sinks to the bottom. (gc and the unread-but-bookmarked g2 both qualify and
|
||||
// tie on score, so we assert on channel rather than a specific id.)
|
||||
if got[0].Channel != "gaming" {
|
||||
t.Fatalf("expected a gaming story first, got channel %q (id %d)", got[0].Channel, got[0].ID)
|
||||
}
|
||||
if got[len(got)-1].ID != tc {
|
||||
t.Fatalf("expected tech candidate ranked last, got id %d", got[len(got)-1].ID)
|
||||
}
|
||||
// Read stories must never appear.
|
||||
for _, s := range got {
|
||||
if s.ID == g1 {
|
||||
t.Fatalf("read story g1 leaked into ForYou")
|
||||
}
|
||||
}
|
||||
// Sanity: the gaming candidate that was never touched should be present.
|
||||
var sawGC bool
|
||||
for _, s := range got {
|
||||
if s.ID == gc {
|
||||
sawGC = true
|
||||
}
|
||||
}
|
||||
if !sawGC {
|
||||
t.Fatalf("expected unread gaming candidate gc in results")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForYou_NoHistoryReturnsNil(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedStoryFull(t, "a", "tech", "TechSite", "Something", "")
|
||||
got, err := ForYou("nobody", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ForYou: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expected nil for a user with no history, got %d rows", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelatedStories_OnTopicExcludesSelf(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seed := seedStoryFull(t, "seed", "tech", "TechSite",
|
||||
"Apple unveils new iPhone camera", "The new camera sensor is larger.")
|
||||
rel := seedStoryFull(t, "rel", "tech", "OtherSite",
|
||||
"iPhone camera teardown reveals sensor", "A look at the new iPhone camera.")
|
||||
seedStoryFull(t, "off", "gaming", "GameSite",
|
||||
"Nintendo announces handheld", "A brand new portable console.")
|
||||
|
||||
got, err := RelatedStories(seed, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("RelatedStories: %v", err)
|
||||
}
|
||||
if len(got) == 0 {
|
||||
t.Fatal("expected at least one related story")
|
||||
}
|
||||
for _, s := range got {
|
||||
if s.ID == seed {
|
||||
t.Fatal("seed story returned as its own related")
|
||||
}
|
||||
}
|
||||
if got[0].ID != rel {
|
||||
t.Fatalf("expected the on-topic iPhone story first, got id %d", got[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRelatedFTSQuery(t *testing.T) {
|
||||
// Stopwords and 1-char tokens drop out; the rest become an OR of quoted terms.
|
||||
q := buildRelatedFTSQuery("The new iPhone camera is a big deal")
|
||||
if q == "" {
|
||||
t.Fatal("expected a non-empty query")
|
||||
}
|
||||
for _, bad := range []string{`"the"`, `"is"`, `"new"`} {
|
||||
if strings.Contains(q, bad) {
|
||||
t.Fatalf("stopword leaked into query: %s in %q", bad, q)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{`"iphone"`, `"camera"`} {
|
||||
if !strings.Contains(q, want) {
|
||||
t.Fatalf("expected %s in query %q", want, q)
|
||||
}
|
||||
}
|
||||
if buildRelatedFTSQuery("the a of to") != "" {
|
||||
t.Fatal("expected empty query when only stopwords remain")
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,18 @@ CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Per-user read + bookmark state for signed-in visitors, keyed by OIDC subject.
|
||||
-- One row carries both signals; a NULL timestamp means "not set". A row with
|
||||
-- both timestamps NULL is meaningless and is pruned, so presence of a row means
|
||||
-- the story is read, bookmarked, or both.
|
||||
CREATE TABLE IF NOT EXISTS user_story_state (
|
||||
user_sub TEXT NOT NULL,
|
||||
story_id INTEGER NOT NULL,
|
||||
read_at INTEGER,
|
||||
bookmarked_at INTEGER,
|
||||
PRIMARY KEY (user_sub, story_id)
|
||||
);
|
||||
|
||||
-- Aggregate web usage. page_views holds running view counts keyed by a coarse
|
||||
-- path label ("home", channel slug, …) and the UTC day, so we can report both
|
||||
-- all-time totals and per-day breakdowns without storing any per-request rows.
|
||||
@@ -64,6 +76,35 @@ CREATE TABLE IF NOT EXISTS page_views (
|
||||
PRIMARY KEY (path, day)
|
||||
);
|
||||
|
||||
-- Per-source poll health, one row per configured feed (keyed by source name).
|
||||
-- Written on every poll (success and failure) so the owner-facing dashboard can
|
||||
-- show which feeds are healthy without keeping the poller's in-memory state.
|
||||
-- last_success_at / last_item_count survive failures so a broken feed still
|
||||
-- shows when it last worked and how much it last returned.
|
||||
CREATE TABLE IF NOT EXISTS source_health (
|
||||
source TEXT PRIMARY KEY,
|
||||
last_poll_at INTEGER, -- unix, most recent poll attempt
|
||||
last_success_at INTEGER, -- unix, most recent successful fetch
|
||||
last_error TEXT, -- last failure message ('' when healthy)
|
||||
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
||||
last_item_count INTEGER NOT NULL DEFAULT 0, -- items in the last successful fetch
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Web Push subscriptions for signed-in users, one row per browser/device
|
||||
-- endpoint. p256dh + auth are the client's encryption keys (RFC 8291); the
|
||||
-- server needs them to encrypt each push. last_notified_at is the per-endpoint
|
||||
-- digest watermark: the sender only counts stories seen after it. A user can
|
||||
-- have several endpoints (phone, desktop) — each is notified independently.
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
endpoint TEXT PRIMARY KEY,
|
||||
user_sub TEXT NOT NULL,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_notified_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Privacy-preserving daily unique estimate. visitor is a salted hash of
|
||||
-- IP+User-Agent; the salt rotates every UTC day and is never persisted, so the
|
||||
-- hashes are irreversible and cannot be linked across days. We keep only enough
|
||||
@@ -88,6 +129,9 @@ CREATE INDEX IF NOT EXISTS idx_reactions_post_guid ON reactions(post_guid);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_reactions_event_id ON reactions(event_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_page_views_day ON page_views(day);
|
||||
CREATE INDEX IF NOT EXISTS idx_daily_visitors_day ON daily_visitors(day);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_state_bookmarks ON user_story_state(user_sub, bookmarked_at) WHERE bookmarked_at IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_user_state_reads ON user_story_state(user_sub, read_at) WHERE read_at IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_push_sub_user ON push_subscriptions(user_sub);
|
||||
`
|
||||
|
||||
const ftsSchema = `
|
||||
|
||||
149
internal/storage/sourcehealth.go
Normal file
149
internal/storage/sourcehealth.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// SourceHealth is one source's persisted poll health, as written by the poller.
|
||||
type SourceHealth struct {
|
||||
Source string
|
||||
LastPollAt int64 // 0 = never polled
|
||||
LastSuccessAt int64 // 0 = never succeeded
|
||||
LastError string
|
||||
ConsecutiveFailures int
|
||||
LastItemCount int
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
// SourceContentStat is per-source content derived from the stories table (plus
|
||||
// post_log for last-posted), independent of poll health.
|
||||
type SourceContentStat struct {
|
||||
Source string
|
||||
Total int // stories currently retained for this source
|
||||
Classified int // of those, how many are classified
|
||||
Paywalled int // of those, how many are gated
|
||||
LastSeenAt int64 // MAX(seen_at); 0 = none
|
||||
LastPostedAt int64 // MAX(post_log.posted_at) joined by guid; 0 = never posted
|
||||
}
|
||||
|
||||
// RecordPollResult upserts a source's health row after a poll attempt. On
|
||||
// success it clears the error and failure counter and records the item count;
|
||||
// on failure it bumps consecutive_failures and stores the message while
|
||||
// preserving the last successful timestamp and item count. Fire-and-forget:
|
||||
// a failure here must never disrupt polling, so errors are logged and swallowed.
|
||||
func RecordPollResult(source string, ok bool, itemCount int, pollErr error) {
|
||||
now := nowUnix()
|
||||
if ok {
|
||||
exec("record poll success",
|
||||
`INSERT INTO source_health
|
||||
(source, last_poll_at, last_success_at, last_error, consecutive_failures, last_item_count, updated_at)
|
||||
VALUES (?, ?, ?, '', 0, ?, ?)
|
||||
ON CONFLICT(source) DO UPDATE SET
|
||||
last_poll_at = excluded.last_poll_at,
|
||||
last_success_at = excluded.last_success_at,
|
||||
last_error = '',
|
||||
consecutive_failures = 0,
|
||||
last_item_count = excluded.last_item_count,
|
||||
updated_at = excluded.updated_at`,
|
||||
source, now, now, itemCount, now)
|
||||
return
|
||||
}
|
||||
msg := ""
|
||||
if pollErr != nil {
|
||||
msg = pollErr.Error()
|
||||
}
|
||||
exec("record poll failure",
|
||||
`INSERT INTO source_health
|
||||
(source, last_poll_at, last_success_at, last_error, consecutive_failures, last_item_count, updated_at)
|
||||
VALUES (?, ?, NULL, ?, 1, 0, ?)
|
||||
ON CONFLICT(source) DO UPDATE SET
|
||||
last_poll_at = excluded.last_poll_at,
|
||||
last_error = excluded.last_error,
|
||||
consecutive_failures = source_health.consecutive_failures + 1,
|
||||
updated_at = excluded.updated_at`,
|
||||
source, now, msg, now)
|
||||
}
|
||||
|
||||
// ListSourceHealth returns the poll-health row for every source that has been
|
||||
// polled at least once, keyed by source name.
|
||||
func ListSourceHealth() (map[string]SourceHealth, error) {
|
||||
rows, err := Get().Query(
|
||||
`SELECT source, last_poll_at, last_success_at, last_error,
|
||||
consecutive_failures, last_item_count, updated_at
|
||||
FROM source_health`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list source health: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]SourceHealth)
|
||||
for rows.Next() {
|
||||
var h SourceHealth
|
||||
var lastPoll, lastSuccess sql.NullInt64
|
||||
var lastErr sql.NullString
|
||||
if err := rows.Scan(&h.Source, &lastPoll, &lastSuccess, &lastErr,
|
||||
&h.ConsecutiveFailures, &h.LastItemCount, &h.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.LastPollAt = lastPoll.Int64
|
||||
h.LastSuccessAt = lastSuccess.Int64
|
||||
h.LastError = lastErr.String
|
||||
out[h.Source] = h
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SourceContentStats derives per-source content counts from the stories table,
|
||||
// with last-posted joined from post_log by guid. Keyed by source name. Only
|
||||
// sources with at least one retained story appear; the caller pads out the rest
|
||||
// from its configured source list.
|
||||
func SourceContentStats() (map[string]SourceContentStat, error) {
|
||||
out := make(map[string]SourceContentStat)
|
||||
|
||||
rows, err := Get().Query(
|
||||
`SELECT source,
|
||||
COUNT(*),
|
||||
COALESCE(SUM(classified), 0),
|
||||
COALESCE(SUM(paywalled), 0),
|
||||
COALESCE(MAX(seen_at), 0)
|
||||
FROM stories
|
||||
GROUP BY source`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("source content stats: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var st SourceContentStat
|
||||
if err := rows.Scan(&st.Source, &st.Total, &st.Classified, &st.Paywalled, &st.LastSeenAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[st.Source] = st
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Last-posted per source, joined by guid. Kept separate so sources with
|
||||
// stories but no posts still appear above with a zero last-posted.
|
||||
prows, err := Get().Query(
|
||||
`SELECT s.source, MAX(p.posted_at)
|
||||
FROM post_log p
|
||||
JOIN stories s ON s.guid = p.guid
|
||||
GROUP BY s.source`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("source last-posted: %w", err)
|
||||
}
|
||||
defer prows.Close()
|
||||
for prows.Next() {
|
||||
var source string
|
||||
var lastPosted int64
|
||||
if err := prows.Scan(&source, &lastPosted); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st := out[source]
|
||||
st.Source = source
|
||||
st.LastPostedAt = lastPosted
|
||||
out[source] = st
|
||||
}
|
||||
return out, prows.Err()
|
||||
}
|
||||
104
internal/storage/sourcehealth_test.go
Normal file
104
internal/storage/sourcehealth_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRecordPollResult_SuccessThenFailure(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
// First a successful poll returning 7 items.
|
||||
RecordPollResult("Feed A", true, 7, nil)
|
||||
h, err := ListSourceHealth()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, ok := h["Feed A"]
|
||||
if !ok {
|
||||
t.Fatal("expected a health row for Feed A")
|
||||
}
|
||||
if a.LastItemCount != 7 || a.ConsecutiveFailures != 0 || a.LastError != "" {
|
||||
t.Errorf("after success: items=%d fails=%d err=%q", a.LastItemCount, a.ConsecutiveFailures, a.LastError)
|
||||
}
|
||||
if a.LastPollAt == 0 || a.LastSuccessAt == 0 {
|
||||
t.Errorf("after success: last_poll=%d last_success=%d, want both set", a.LastPollAt, a.LastSuccessAt)
|
||||
}
|
||||
successTS := a.LastSuccessAt
|
||||
|
||||
// Two failures in a row: failures accumulate, but the last-success timestamp
|
||||
// and item count are preserved so the dashboard still shows when it last worked.
|
||||
RecordPollResult("Feed A", false, 0, errors.New("dial tcp: timeout"))
|
||||
RecordPollResult("Feed A", false, 0, errors.New("502 bad gateway"))
|
||||
h, _ = ListSourceHealth()
|
||||
a = h["Feed A"]
|
||||
if a.ConsecutiveFailures != 2 {
|
||||
t.Errorf("consecutive failures = %d, want 2", a.ConsecutiveFailures)
|
||||
}
|
||||
if a.LastError != "502 bad gateway" {
|
||||
t.Errorf("last error = %q, want %q", a.LastError, "502 bad gateway")
|
||||
}
|
||||
if a.LastSuccessAt != successTS {
|
||||
t.Errorf("last success = %d, want preserved %d", a.LastSuccessAt, successTS)
|
||||
}
|
||||
if a.LastItemCount != 7 {
|
||||
t.Errorf("last item count = %d, want preserved 7", a.LastItemCount)
|
||||
}
|
||||
|
||||
// Recovery clears the error and resets the counter.
|
||||
RecordPollResult("Feed A", true, 3, nil)
|
||||
h, _ = ListSourceHealth()
|
||||
a = h["Feed A"]
|
||||
if a.ConsecutiveFailures != 0 || a.LastError != "" || a.LastItemCount != 3 {
|
||||
t.Errorf("after recovery: fails=%d err=%q items=%d", a.ConsecutiveFailures, a.LastError, a.LastItemCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceContentStats(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
// Source X: two classified stories, one paywalled; one posted.
|
||||
InsertStory(&Story{GUID: "x1", Headline: "X one", ArticleURL: "https://x.com/1", Source: "X", Channel: "tech", Classified: true, SeenAt: 100})
|
||||
InsertStory(&Story{GUID: "x2", Headline: "X two", ArticleURL: "https://x.com/2", Source: "X", Channel: "tech", Classified: true, Paywalled: true, SeenAt: 200})
|
||||
// Source Y: one unclassified story, never posted.
|
||||
InsertStory(&Story{GUID: "y1", Headline: "Y one", ArticleURL: "https://y.com/1", Source: "Y", SeenAt: 150})
|
||||
|
||||
InsertPostLog("x1", "tech", "$e1", "", false)
|
||||
// Backdate/forward the post so we can assert MAX(posted_at).
|
||||
Get().Exec(`UPDATE post_log SET posted_at = ? WHERE guid = ?`, int64(500), "x1")
|
||||
|
||||
stats, err := SourceContentStats()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
x := stats["X"]
|
||||
if x.Total != 2 || x.Classified != 2 || x.Paywalled != 1 {
|
||||
t.Errorf("X: total=%d classified=%d paywalled=%d, want 2/2/1", x.Total, x.Classified, x.Paywalled)
|
||||
}
|
||||
if x.LastSeenAt != 200 {
|
||||
t.Errorf("X last seen = %d, want 200", x.LastSeenAt)
|
||||
}
|
||||
if x.LastPostedAt != 500 {
|
||||
t.Errorf("X last posted = %d, want 500", x.LastPostedAt)
|
||||
}
|
||||
|
||||
y := stats["Y"]
|
||||
if y.Total != 1 || y.Classified != 0 || y.Paywalled != 0 {
|
||||
t.Errorf("Y: total=%d classified=%d paywalled=%d, want 1/0/0", y.Total, y.Classified, y.Paywalled)
|
||||
}
|
||||
if y.LastPostedAt != 0 {
|
||||
t.Errorf("Y last posted = %d, want 0 (never posted)", y.LastPostedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSourceHealth_Empty(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
h, err := ListSourceHealth()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(h) != 0 {
|
||||
t.Errorf("expected no health rows, got %d", len(h))
|
||||
}
|
||||
}
|
||||
139
internal/storage/userstate.go
Normal file
139
internal/storage/userstate.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SetRead marks (read=true) or clears (read=false) the read flag for one story
|
||||
// and one signed-in user (keyed by OIDC subject). Read and bookmark state share
|
||||
// a row; clearing the last remaining flag removes the row.
|
||||
func SetRead(sub string, storyID int64, read bool) error {
|
||||
var ts any
|
||||
if read {
|
||||
ts = nowUnix()
|
||||
}
|
||||
_, err := Get().Exec(`
|
||||
INSERT INTO user_story_state (user_sub, story_id, read_at, bookmarked_at)
|
||||
VALUES (?, ?, ?, NULL)
|
||||
ON CONFLICT(user_sub, story_id) DO UPDATE SET read_at = excluded.read_at`,
|
||||
sub, storyID, ts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set read: %w", err)
|
||||
}
|
||||
return pruneEmptyState(sub, storyID)
|
||||
}
|
||||
|
||||
// SetBookmark adds (on=true) or removes (on=false) a bookmark for one story and
|
||||
// one signed-in user. See SetRead for the shared-row semantics.
|
||||
func SetBookmark(sub string, storyID int64, on bool) error {
|
||||
var ts any
|
||||
if on {
|
||||
ts = nowUnix()
|
||||
}
|
||||
_, err := Get().Exec(`
|
||||
INSERT INTO user_story_state (user_sub, story_id, read_at, bookmarked_at)
|
||||
VALUES (?, ?, NULL, ?)
|
||||
ON CONFLICT(user_sub, story_id) DO UPDATE SET bookmarked_at = excluded.bookmarked_at`,
|
||||
sub, storyID, ts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set bookmark: %w", err)
|
||||
}
|
||||
return pruneEmptyState(sub, storyID)
|
||||
}
|
||||
|
||||
// pruneEmptyState drops a row once neither flag is set, keeping the table to
|
||||
// only meaningful state.
|
||||
func pruneEmptyState(sub string, storyID int64) error {
|
||||
_, err := Get().Exec(
|
||||
`DELETE FROM user_story_state
|
||||
WHERE user_sub = ? AND story_id = ? AND read_at IS NULL AND bookmarked_at IS NULL`,
|
||||
sub, storyID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prune user state: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserStoryState reports, for the given story ids, which are read and which are
|
||||
// bookmarked by this user. Both maps contain only ids whose flag is set, so a
|
||||
// missing key means false. An empty sub or id list returns empty maps.
|
||||
func UserStoryState(sub string, ids []int64) (read, bookmarked map[int64]bool, err error) {
|
||||
read = make(map[int64]bool)
|
||||
bookmarked = make(map[int64]bool)
|
||||
if sub == "" || len(ids) == 0 {
|
||||
return read, bookmarked, nil
|
||||
}
|
||||
q := `SELECT story_id, read_at, bookmarked_at FROM user_story_state
|
||||
WHERE user_sub = ? AND story_id IN (` + placeholders(len(ids)) + `)`
|
||||
args := make([]any, 0, len(ids)+1)
|
||||
args = append(args, sub)
|
||||
for _, id := range ids {
|
||||
args = append(args, id)
|
||||
}
|
||||
rows, err := Get().Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("user story state: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var r, b sql.NullInt64
|
||||
if err := rows.Scan(&id, &r, &b); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if r.Valid {
|
||||
read[id] = true
|
||||
}
|
||||
if b.Valid {
|
||||
bookmarked[id] = true
|
||||
}
|
||||
}
|
||||
return read, bookmarked, rows.Err()
|
||||
}
|
||||
|
||||
// ListBookmarks returns the user's bookmarked stories, most recently bookmarked
|
||||
// first, restricted to still-classified stories.
|
||||
func ListBookmarks(sub string, limit, offset int) ([]Story, error) {
|
||||
rows, err := Get().Query(
|
||||
`SELECT s.id, s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at,
|
||||
EXISTS(SELECT 1 FROM post_log p WHERE p.guid = s.guid) AS posted
|
||||
FROM user_story_state u
|
||||
JOIN stories s ON s.id = u.story_id
|
||||
WHERE u.user_sub = ?
|
||||
AND u.bookmarked_at IS NOT NULL
|
||||
AND s.classified = 1
|
||||
ORDER BY u.bookmarked_at DESC, u.story_id DESC
|
||||
LIMIT ? OFFSET ?`, sub, limit, offset)
|
||||
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.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.Paywalled, &s.SeenAt, &s.Posted); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CountBookmarks returns how many stories the user has bookmarked.
|
||||
func CountBookmarks(sub string) (int, error) {
|
||||
var n int
|
||||
err := Get().QueryRow(
|
||||
`SELECT COUNT(*) FROM user_story_state WHERE user_sub = ? AND bookmarked_at IS NOT NULL`,
|
||||
sub).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// placeholders returns "?, ?, …" with n slots for an IN clause.
|
||||
func placeholders(n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Repeat("?, ", n-1) + "?"
|
||||
}
|
||||
126
internal/storage/userstate_test.go
Normal file
126
internal/storage/userstate_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package storage
|
||||
|
||||
import "testing"
|
||||
|
||||
func seedStory(t *testing.T, guid string) int64 {
|
||||
t.Helper()
|
||||
s := &Story{
|
||||
GUID: guid,
|
||||
Headline: "Headline " + guid,
|
||||
Lede: "Lede.",
|
||||
ArticleURL: "https://example.com/" + guid,
|
||||
Source: "Test Source",
|
||||
Channel: "tech",
|
||||
Classified: true,
|
||||
SeenAt: 1700000000,
|
||||
}
|
||||
if err := InsertStory(s); err != nil {
|
||||
t.Fatalf("insert story %s: %v", guid, err)
|
||||
}
|
||||
var id int64
|
||||
if err := Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, guid).Scan(&id); err != nil {
|
||||
t.Fatalf("lookup id %s: %v", guid, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestUserStoryState_ReadAndBookmark(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
id1 := seedStory(t, "s1")
|
||||
id2 := seedStory(t, "s2")
|
||||
const sub = "ak-sub-1"
|
||||
|
||||
// Nothing set yet.
|
||||
read, marks, err := UserStoryState(sub, []int64{id1, id2})
|
||||
if err != nil {
|
||||
t.Fatalf("initial state: %v", err)
|
||||
}
|
||||
if len(read) != 0 || len(marks) != 0 {
|
||||
t.Fatalf("expected empty state, got read=%v bookmarked=%v", read, marks)
|
||||
}
|
||||
|
||||
// Mark id1 read, bookmark id1 and id2.
|
||||
if err := SetRead(sub, id1, true); err != nil {
|
||||
t.Fatalf("set read: %v", err)
|
||||
}
|
||||
if err := SetBookmark(sub, id1, true); err != nil {
|
||||
t.Fatalf("set bookmark id1: %v", err)
|
||||
}
|
||||
if err := SetBookmark(sub, id2, true); err != nil {
|
||||
t.Fatalf("set bookmark id2: %v", err)
|
||||
}
|
||||
read, marks, _ = UserStoryState(sub, []int64{id1, id2})
|
||||
if !read[id1] || read[id2] {
|
||||
t.Fatalf("read map wrong: %v", read)
|
||||
}
|
||||
if !marks[id1] || !marks[id2] {
|
||||
t.Fatalf("bookmark map wrong: %v", marks)
|
||||
}
|
||||
// id1 carries both flags in a single row.
|
||||
if !read[id1] || !marks[id1] {
|
||||
t.Fatalf("expected id1 read+bookmarked, read=%v marks=%v", read, marks)
|
||||
}
|
||||
|
||||
// ListBookmarks orders newest bookmark first, tiebreaking on story id so the
|
||||
// result is deterministic within a single second.
|
||||
bm, err := ListBookmarks(sub, 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("list bookmarks: %v", err)
|
||||
}
|
||||
if len(bm) != 2 {
|
||||
t.Fatalf("expected 2 bookmarks, got %d", len(bm))
|
||||
}
|
||||
if bm[0].ID != id2 || bm[1].ID != id1 {
|
||||
t.Fatalf("bookmark order wrong: %d then %d", bm[0].ID, bm[1].ID)
|
||||
}
|
||||
if n, _ := CountBookmarks(sub); n != 2 {
|
||||
t.Fatalf("count bookmarks = %d, want 2", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserStoryState_ClearRemovesRow(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
id := seedStory(t, "s1")
|
||||
const sub = "ak-sub-1"
|
||||
|
||||
if err := SetRead(sub, id, true); err != nil {
|
||||
t.Fatalf("set read: %v", err)
|
||||
}
|
||||
if err := SetRead(sub, id, false); err != nil {
|
||||
t.Fatalf("unset read: %v", err)
|
||||
}
|
||||
// Row should be gone since neither flag is set.
|
||||
var n int
|
||||
if err := Get().QueryRow(`SELECT COUNT(*) FROM user_story_state WHERE user_sub = ?`, sub).Scan(&n); err != nil {
|
||||
t.Fatalf("count rows: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("expected row pruned, found %d", n)
|
||||
}
|
||||
|
||||
// Setting read then bookmark then clearing only read must keep the row.
|
||||
_ = SetRead(sub, id, true)
|
||||
_ = SetBookmark(sub, id, true)
|
||||
_ = SetRead(sub, id, false)
|
||||
read, marks, _ := UserStoryState(sub, []int64{id})
|
||||
if read[id] {
|
||||
t.Fatalf("expected not read")
|
||||
}
|
||||
if !marks[id] {
|
||||
t.Fatalf("expected still bookmarked after clearing read")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserStoryState_ScopedBySub(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
id := seedStory(t, "s1")
|
||||
_ = SetBookmark("user-a", id, true)
|
||||
|
||||
read, marks, _ := UserStoryState("user-b", []int64{id})
|
||||
if len(read) != 0 || len(marks) != 0 {
|
||||
t.Fatalf("user-b should see no state, got read=%v marks=%v", read, marks)
|
||||
}
|
||||
if n, _ := CountBookmarks("user-b"); n != 0 {
|
||||
t.Fatalf("user-b count = %d, want 0", n)
|
||||
}
|
||||
}
|
||||
303
internal/web/feed.go
Normal file
303
internal/web/feed.go
Normal file
@@ -0,0 +1,303 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// feedItemLimit caps how many stories each outbound feed carries.
|
||||
const feedItemLimit = 50
|
||||
|
||||
// feedCacheControl is a short cache window; feeds refresh a few times an hour.
|
||||
const feedCacheControl = "public, max-age=300"
|
||||
|
||||
const (
|
||||
rssContentNS = "http://purl.org/rss/1.0/modules/content/"
|
||||
rssAtomNS = "http://www.w3.org/2005/Atom"
|
||||
jsonFeedVer = "https://jsonfeed.org/version/1.1"
|
||||
)
|
||||
|
||||
// --- RSS 2.0 ---
|
||||
|
||||
type rssFeedXML struct {
|
||||
XMLName xml.Name `xml:"rss"`
|
||||
Version string `xml:"version,attr"`
|
||||
ContentNS string `xml:"xmlns:content,attr"`
|
||||
AtomNS string `xml:"xmlns:atom,attr"`
|
||||
Channel rssChannel `xml:"channel"`
|
||||
}
|
||||
|
||||
type rssChannel struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
Description string `xml:"description"`
|
||||
Language string `xml:"language,omitempty"`
|
||||
LastBuildDate string `xml:"lastBuildDate,omitempty"`
|
||||
Generator string `xml:"generator,omitempty"`
|
||||
AtomLink rssAtomLink `xml:"atom:link"`
|
||||
Items []rssItem `xml:"item"`
|
||||
}
|
||||
|
||||
type rssAtomLink struct {
|
||||
Href string `xml:"href,attr"`
|
||||
Rel string `xml:"rel,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
}
|
||||
|
||||
type rssItem struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
GUID rssGUID `xml:"guid"`
|
||||
PubDate string `xml:"pubDate,omitempty"`
|
||||
Category string `xml:"category,omitempty"`
|
||||
Description string `xml:"description"`
|
||||
Content *rssContent `xml:"content:encoded,omitempty"`
|
||||
}
|
||||
|
||||
type rssGUID struct {
|
||||
IsPermaLink string `xml:"isPermaLink,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type rssContent struct {
|
||||
Value string `xml:",cdata"`
|
||||
}
|
||||
|
||||
// --- JSON Feed 1.1 ---
|
||||
|
||||
type jsonFeed struct {
|
||||
Version string `json:"version"`
|
||||
Title string `json:"title"`
|
||||
HomePageURL string `json:"home_page_url"`
|
||||
FeedURL string `json:"feed_url"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Items []jsonFeedItem `json:"items"`
|
||||
}
|
||||
|
||||
type jsonFeedItem struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
ContentText string `json:"content_text,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
DatePublished string `json:"date_published,omitempty"`
|
||||
Authors []jsonFeedAuthor `json:"authors,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
type jsonFeedAuthor struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// handleFeedXML serves an RSS 2.0 feed for the given channel ("" = all channels).
|
||||
func (s *Server) handleFeedXML(w http.ResponseWriter, r *http.Request, channel string) {
|
||||
stories, err := storage.ListForFeed(channel, feedItemLimit)
|
||||
if err != nil {
|
||||
slog.Error("web: feed query failed", "channel", channel, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
base := s.publicBase(r)
|
||||
title, homeURL, desc := s.feedMeta(channel, base)
|
||||
selfURL := base + feedPath(channel, "xml")
|
||||
|
||||
feed := rssFeedXML{
|
||||
Version: "2.0",
|
||||
ContentNS: rssContentNS,
|
||||
AtomNS: rssAtomNS,
|
||||
Channel: rssChannel{
|
||||
Title: title,
|
||||
Link: homeURL,
|
||||
Description: desc,
|
||||
Language: "en",
|
||||
Generator: "Pete",
|
||||
AtomLink: rssAtomLink{Href: selfURL, Rel: "self", Type: "application/rss+xml"},
|
||||
Items: make([]rssItem, 0, len(stories)),
|
||||
},
|
||||
}
|
||||
if len(stories) > 0 {
|
||||
feed.Channel.LastBuildDate = feedTime(stories[0]).UTC().Format(time.RFC1123Z)
|
||||
}
|
||||
for _, st := range stories {
|
||||
link := storyLink(st)
|
||||
item := rssItem{
|
||||
Title: st.Headline,
|
||||
Link: link,
|
||||
GUID: rssGUID{IsPermaLink: "false", Value: st.GUID},
|
||||
PubDate: feedTime(st).UTC().Format(time.RFC1123Z),
|
||||
Category: st.Channel,
|
||||
Description: st.Lede,
|
||||
}
|
||||
if html := contentToHTML(st.Content); html != "" {
|
||||
item.Content = &rssContent{Value: html}
|
||||
}
|
||||
feed.Channel.Items = append(feed.Channel.Items, item)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", feedCacheControl)
|
||||
if _, err := w.Write([]byte(xml.Header)); err != nil {
|
||||
return
|
||||
}
|
||||
enc := xml.NewEncoder(w)
|
||||
enc.Indent("", " ")
|
||||
if err := enc.Encode(feed); err != nil {
|
||||
slog.Error("web: rss encode failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleFeedJSON serves a JSON Feed 1.1 for the given channel ("" = all).
|
||||
func (s *Server) handleFeedJSON(w http.ResponseWriter, r *http.Request, channel string) {
|
||||
stories, err := storage.ListForFeed(channel, feedItemLimit)
|
||||
if err != nil {
|
||||
slog.Error("web: feed query failed", "channel", channel, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
base := s.publicBase(r)
|
||||
title, homeURL, desc := s.feedMeta(channel, base)
|
||||
|
||||
feed := jsonFeed{
|
||||
Version: jsonFeedVer,
|
||||
Title: title,
|
||||
HomePageURL: homeURL,
|
||||
FeedURL: base + feedPath(channel, "json"),
|
||||
Description: desc,
|
||||
Items: make([]jsonFeedItem, 0, len(stories)),
|
||||
}
|
||||
for _, st := range stories {
|
||||
item := jsonFeedItem{
|
||||
ID: st.GUID,
|
||||
URL: storyLink(st),
|
||||
Title: st.Headline,
|
||||
ContentText: strings.TrimSpace(st.Content),
|
||||
Summary: st.Lede,
|
||||
Image: st.ImageURL,
|
||||
DatePublished: feedTime(st).UTC().Format(time.RFC3339),
|
||||
Tags: []string{st.Channel},
|
||||
}
|
||||
if item.ContentText == "" {
|
||||
item.ContentText = st.Lede
|
||||
}
|
||||
if st.Source != "" {
|
||||
item.Authors = []jsonFeedAuthor{{Name: st.Source}}
|
||||
}
|
||||
feed.Items = append(feed.Items, item)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/feed+json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", feedCacheControl)
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(feed); err != nil {
|
||||
slog.Error("web: json feed encode failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// feedMeta returns the title, home-page URL, and description for a feed. An
|
||||
// empty channel is the site-wide feed; a slug scopes to that channel.
|
||||
func (s *Server) feedMeta(channel, base string) (title, homeURL, desc string) {
|
||||
site := s.cfg.SiteTitle
|
||||
if site == "" {
|
||||
site = "Pete"
|
||||
}
|
||||
if channel == "" {
|
||||
return site, base + "/", "The latest across every channel."
|
||||
}
|
||||
ch := channelBySlug(channel)
|
||||
return site + " — " + ch.Title, base + "/" + channel, ch.Blurb
|
||||
}
|
||||
|
||||
// feedPath is the site-relative path of a feed, e.g. "/feed.xml" or
|
||||
// "/gaming/feed.json".
|
||||
func feedPath(channel, ext string) string {
|
||||
if channel == "" {
|
||||
return "/feed." + ext
|
||||
}
|
||||
return "/" + channel + "/feed." + ext
|
||||
}
|
||||
|
||||
// storyLink is the canonical outbound link for a story: its canonical URL when
|
||||
// known, otherwise the raw article URL.
|
||||
func storyLink(s storage.Story) string {
|
||||
if s.URLCanonical != "" {
|
||||
return s.URLCanonical
|
||||
}
|
||||
return s.ArticleURL
|
||||
}
|
||||
|
||||
// feedTime picks the best timestamp for a story: its published date when
|
||||
// present, otherwise when Pete first saw it.
|
||||
func feedTime(s storage.Story) time.Time {
|
||||
if s.PublishedAt > 0 {
|
||||
return time.Unix(s.PublishedAt, 0)
|
||||
}
|
||||
return time.Unix(s.SeenAt, 0)
|
||||
}
|
||||
|
||||
// channelBySlug looks up a channel by slug, falling back to a bare title-cased
|
||||
// entry so an unknown slug never panics.
|
||||
func channelBySlug(slug string) Channel {
|
||||
for _, ch := range channels {
|
||||
if ch.Slug == slug {
|
||||
return ch
|
||||
}
|
||||
}
|
||||
return Channel{Slug: slug, Title: slug, Theme: slug}
|
||||
}
|
||||
|
||||
// publicBase returns the site's public origin (scheme://host, no trailing
|
||||
// slash). It prefers the configured base_url and falls back to the request's
|
||||
// host so feeds still carry absolute URLs when base_url is unset.
|
||||
func (s *Server) publicBase(r *http.Request) string {
|
||||
if b := strings.TrimRight(s.cfg.BaseURL, "/"); b != "" {
|
||||
return b
|
||||
}
|
||||
scheme := "http"
|
||||
if r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
|
||||
scheme = "https"
|
||||
}
|
||||
return scheme + "://" + r.Host
|
||||
}
|
||||
|
||||
// contentToHTML turns Pete's stored plain-text article body (paragraphs split by
|
||||
// blank lines, soft line breaks inside) into simple, escaped HTML for RSS
|
||||
// content:encoded. Returns "" when there is no body.
|
||||
func contentToHTML(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, para := range strings.Split(text, "\n\n") {
|
||||
para = strings.TrimSpace(para)
|
||||
if para == "" {
|
||||
continue
|
||||
}
|
||||
b.WriteString("<p>")
|
||||
b.WriteString(strings.ReplaceAll(escapeHTMLText(para), "\n", "<br>"))
|
||||
b.WriteString("</p>")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// escapeHTMLText escapes the five characters that matter inside HTML text so the
|
||||
// article body can't inject markup into content:encoded.
|
||||
func escapeHTMLText(s string) string {
|
||||
return strings.NewReplacer(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
`"`, """,
|
||||
"'", "'",
|
||||
).Replace(s)
|
||||
}
|
||||
210
internal/web/feed_test.go
Normal file
210
internal/web/feed_test.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pete/internal/config"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// TestFeeds exercises the outbound RSS and JSON feeds end to end: a classified
|
||||
// story appears in both formats, carrying its canonical link, full content, and
|
||||
// a real pubDate; a channel-scoped feed excludes stories from other channels.
|
||||
func TestFeeds(t *testing.T) {
|
||||
storage.Close()
|
||||
if err := storage.Init(filepath.Join(t.TempDir(), "feed.db")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { storage.Close() })
|
||||
|
||||
pub := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC).Unix()
|
||||
tech := &storage.Story{
|
||||
GUID: "feed-tech-1",
|
||||
Headline: "Chips & Dips: A Tech Tale",
|
||||
Lede: "The lede for the tech story.",
|
||||
Content: "First paragraph.\n\nSecond paragraph with <html> & symbols.",
|
||||
ArticleURL: "https://example.com/tech/story?utm=x",
|
||||
URLCanonical: "https://example.com/tech/story",
|
||||
Source: "Example Wire",
|
||||
Channel: "tech",
|
||||
Classified: true,
|
||||
SeenAt: time.Now().Unix(),
|
||||
PublishedAt: pub,
|
||||
}
|
||||
gaming := &storage.Story{
|
||||
GUID: "feed-gaming-1",
|
||||
Headline: "A Gaming Headline",
|
||||
Lede: "Gaming lede.",
|
||||
ArticleURL: "https://example.com/gaming/story",
|
||||
Source: "Play Wire",
|
||||
Channel: "gaming",
|
||||
Classified: true,
|
||||
SeenAt: time.Now().Unix(),
|
||||
}
|
||||
for _, st := range []*storage.Story{tech, gaming} {
|
||||
if err := storage.InsertStory(st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0", BaseURL: "https://news.example/"}, nil, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// --- Site-wide RSS ---
|
||||
rw := httptest.NewRecorder()
|
||||
s.handleFeedXML(rw, httptest.NewRequest("GET", "/feed.xml", nil), "")
|
||||
if rw.Code != 200 {
|
||||
t.Fatalf("rss status = %d", rw.Code)
|
||||
}
|
||||
if ct := rw.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/rss+xml") {
|
||||
t.Errorf("rss content-type = %q", ct)
|
||||
}
|
||||
var rss struct {
|
||||
Channel struct {
|
||||
Title string `xml:"title"`
|
||||
AtomLink struct {
|
||||
Href string `xml:"href,attr"`
|
||||
} `xml:"link"`
|
||||
Items []struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
GUID string `xml:"guid"`
|
||||
PubDate string `xml:"pubDate"`
|
||||
Content string `xml:"encoded"` // content:encoded
|
||||
} `xml:"item"`
|
||||
} `xml:"channel"`
|
||||
}
|
||||
if err := xml.Unmarshal(rw.Body.Bytes(), &rss); err != nil {
|
||||
t.Fatalf("rss did not parse as XML: %v\n%s", err, rw.Body.String())
|
||||
}
|
||||
if len(rss.Channel.Items) != 2 {
|
||||
t.Fatalf("rss items = %d, want 2", len(rss.Channel.Items))
|
||||
}
|
||||
// Newest-first: PublishedAt March 2026 vs gaming's seen_at (now) — gaming is
|
||||
// newer, so it sorts first. Find the tech item to assert on.
|
||||
var techItem *struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
GUID string `xml:"guid"`
|
||||
PubDate string `xml:"pubDate"`
|
||||
Content string `xml:"encoded"`
|
||||
}
|
||||
for i := range rss.Channel.Items {
|
||||
if rss.Channel.Items[i].GUID == "feed-tech-1" {
|
||||
techItem = &rss.Channel.Items[i]
|
||||
}
|
||||
}
|
||||
if techItem == nil {
|
||||
t.Fatal("tech item missing from rss")
|
||||
}
|
||||
if techItem.Link != "https://example.com/tech/story" {
|
||||
t.Errorf("rss link = %q, want canonical", techItem.Link)
|
||||
}
|
||||
if techItem.Title != "Chips & Dips: A Tech Tale" {
|
||||
t.Errorf("rss title = %q", techItem.Title)
|
||||
}
|
||||
if !strings.Contains(techItem.Content, "<p>First paragraph.</p>") {
|
||||
t.Errorf("rss content missing paragraph markup: %q", techItem.Content)
|
||||
}
|
||||
if !strings.Contains(techItem.Content, "<html>") {
|
||||
t.Errorf("rss content did not escape embedded markup: %q", techItem.Content)
|
||||
}
|
||||
if !strings.Contains(techItem.PubDate, "2026") {
|
||||
t.Errorf("rss pubDate = %q, want the published date", techItem.PubDate)
|
||||
}
|
||||
if !strings.Contains(rw.Body.String(), `href="https://news.example/feed.xml"`) {
|
||||
t.Errorf("rss missing atom self link with base_url")
|
||||
}
|
||||
|
||||
// --- Site-wide JSON Feed ---
|
||||
rw = httptest.NewRecorder()
|
||||
s.handleFeedJSON(rw, httptest.NewRequest("GET", "/feed.json", nil), "")
|
||||
if rw.Code != 200 {
|
||||
t.Fatalf("json status = %d", rw.Code)
|
||||
}
|
||||
if ct := rw.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/feed+json") {
|
||||
t.Errorf("json content-type = %q", ct)
|
||||
}
|
||||
var jf struct {
|
||||
Version string `json:"version"`
|
||||
FeedURL string `json:"feed_url"`
|
||||
HomePageURL string `json:"home_page_url"`
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
ContentText string `json:"content_text"`
|
||||
Tags []string `json:"tags"`
|
||||
Authors []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"authors"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(rw.Body.Bytes(), &jf); err != nil {
|
||||
t.Fatalf("json feed did not parse: %v", err)
|
||||
}
|
||||
if !strings.Contains(jf.Version, "jsonfeed.org") {
|
||||
t.Errorf("json version = %q", jf.Version)
|
||||
}
|
||||
if jf.FeedURL != "https://news.example/feed.json" {
|
||||
t.Errorf("json feed_url = %q", jf.FeedURL)
|
||||
}
|
||||
if len(jf.Items) != 2 {
|
||||
t.Fatalf("json items = %d, want 2", len(jf.Items))
|
||||
}
|
||||
var jTech *struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
ContentText string `json:"content_text"`
|
||||
Tags []string `json:"tags"`
|
||||
Authors []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"authors"`
|
||||
}
|
||||
for i := range jf.Items {
|
||||
if jf.Items[i].ID == "feed-tech-1" {
|
||||
jTech = &jf.Items[i]
|
||||
}
|
||||
}
|
||||
if jTech == nil {
|
||||
t.Fatal("tech item missing from json feed")
|
||||
}
|
||||
if jTech.URL != "https://example.com/tech/story" {
|
||||
t.Errorf("json url = %q, want canonical", jTech.URL)
|
||||
}
|
||||
if !strings.Contains(jTech.ContentText, "Second paragraph") {
|
||||
t.Errorf("json content_text = %q", jTech.ContentText)
|
||||
}
|
||||
if len(jTech.Tags) != 1 || jTech.Tags[0] != "tech" {
|
||||
t.Errorf("json tags = %v", jTech.Tags)
|
||||
}
|
||||
if len(jTech.Authors) != 1 || jTech.Authors[0].Name != "Example Wire" {
|
||||
t.Errorf("json authors = %v", jTech.Authors)
|
||||
}
|
||||
|
||||
// --- Channel-scoped RSS excludes other channels ---
|
||||
rw = httptest.NewRecorder()
|
||||
s.handleFeedXML(rw, httptest.NewRequest("GET", "/tech/feed.xml", nil), "tech")
|
||||
rss.Channel.Items = nil // xml.Unmarshal appends; clear the site-wide items
|
||||
if err := xml.Unmarshal(rw.Body.Bytes(), &rss); err != nil {
|
||||
t.Fatalf("channel rss parse: %v", err)
|
||||
}
|
||||
if len(rss.Channel.Items) != 1 {
|
||||
t.Fatalf("channel rss items = %d, want 1", len(rss.Channel.Items))
|
||||
}
|
||||
if rss.Channel.Items[0].GUID != "feed-tech-1" {
|
||||
t.Errorf("channel rss wrong item: %q", rss.Channel.Items[0].GUID)
|
||||
}
|
||||
if !strings.Contains(rss.Channel.Title, "Tech") {
|
||||
t.Errorf("channel rss title = %q, want channel name", rss.Channel.Title)
|
||||
}
|
||||
}
|
||||
@@ -45,16 +45,20 @@ func toView(s storage.Story) StoryView {
|
||||
}
|
||||
|
||||
type pageData struct {
|
||||
SiteTitle string
|
||||
Channels []Channel
|
||||
Active string // slug of active channel, "" for landing
|
||||
Weather Weather
|
||||
Phase string // when set, forces day/dawn/dusk/night and disables the clock-driven phase JS
|
||||
AllSources template.JS // JSON array of {name, channel} for the settings panel
|
||||
AuthEnabled bool // sign-in is available
|
||||
User *SessionUser // nil for anonymous visitors
|
||||
UserPrefs template.JS // signed-in user's stored prefs blob (JSON), or "null"
|
||||
Path string // current request path, for post-login return
|
||||
SiteTitle string
|
||||
Channels []Channel
|
||||
Active string // slug of active channel, "" for landing
|
||||
Weather Weather
|
||||
Phase string // when set, forces day/dawn/dusk/night and disables the clock-driven phase JS
|
||||
AllSources template.JS // JSON array of {name, channel} for the settings panel
|
||||
AuthEnabled bool // sign-in is available
|
||||
User *SessionUser // nil for anonymous visitors
|
||||
UserPrefs template.JS // signed-in user's stored prefs blob (JSON), or "null"
|
||||
Path string // current request path, for post-login return
|
||||
PostingEnabled bool // false = web-only mode; hide Matrix-posting UI
|
||||
IsAdmin bool // signed-in user is on the admin allowlist (shows /status link)
|
||||
PushEnabled bool // Web Push is configured (shows the notifications toggle to signed-in users)
|
||||
PushPublicKey string // VAPID public key handed to the client to subscribe
|
||||
}
|
||||
|
||||
type channelPage struct {
|
||||
@@ -74,6 +78,7 @@ type indexPage struct {
|
||||
JustPosted []StoryView
|
||||
Stats []channelStat
|
||||
Latest []StoryView
|
||||
ForYou []StoryView // personalized rail; empty for anon / no-history users
|
||||
}
|
||||
|
||||
type channelStat struct {
|
||||
@@ -105,17 +110,21 @@ func (s *Server) base(r *http.Request) pageData {
|
||||
srcJSON = []byte("[]")
|
||||
}
|
||||
d := pageData{
|
||||
SiteTitle: s.cfg.SiteTitle,
|
||||
Channels: channels,
|
||||
Weather: currentWeather(time.Now()),
|
||||
AllSources: jsForScript(srcJSON),
|
||||
AuthEnabled: s.auth != nil,
|
||||
UserPrefs: template.JS("null"),
|
||||
Path: r.URL.Path,
|
||||
SiteTitle: s.cfg.SiteTitle,
|
||||
Channels: channels,
|
||||
Weather: currentWeather(time.Now()),
|
||||
AllSources: jsForScript(srcJSON),
|
||||
AuthEnabled: s.auth != nil,
|
||||
UserPrefs: template.JS("null"),
|
||||
Path: r.URL.Path,
|
||||
PostingEnabled: s.postingEnabled,
|
||||
PushEnabled: s.auth != nil && s.cfg.Push.Enabled,
|
||||
PushPublicKey: s.cfg.Push.VAPIDPublicKey,
|
||||
}
|
||||
if s.auth != nil {
|
||||
if u := s.auth.userFromRequest(r); u != nil {
|
||||
d.User = u
|
||||
d.IsAdmin = s.adminSubs[u.Sub]
|
||||
if blob, err := storage.GetUserPrefs(u.Sub); err == nil && blob != "" {
|
||||
d.UserPrefs = jsForScript([]byte(blob))
|
||||
}
|
||||
@@ -176,9 +185,54 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
Stats: stats,
|
||||
Latest: latest,
|
||||
}
|
||||
// For signed-in users with some read/bookmark history, lead with a small
|
||||
// personalized rail. ForYou returns nothing for anon / no-history users, so
|
||||
// the section simply doesn't render for them.
|
||||
if s.auth != nil {
|
||||
if u := s.auth.userFromRequest(r); u != nil {
|
||||
const forYouLimit = 8
|
||||
if fy, err := storage.ForYou(u.Sub, forYouLimit); err != nil {
|
||||
slog.Error("web: for-you rail failed", "sub", u.Sub, "err", err)
|
||||
} else {
|
||||
for _, row := range fy {
|
||||
data.ForYou = append(data.ForYou, toView(row))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
s.render(w, "index", data)
|
||||
}
|
||||
|
||||
type forYouPage struct {
|
||||
pageData
|
||||
Stories []StoryView
|
||||
}
|
||||
|
||||
// handleForYou renders the dedicated personalized feed. Anonymous visitors are
|
||||
// sent to sign-in (the route is only registered when auth is on).
|
||||
func (s *Server) handleForYou(w http.ResponseWriter, r *http.Request) {
|
||||
u := s.auth.userFromRequest(r)
|
||||
if u == nil {
|
||||
http.Redirect(w, r, "/auth/login?next=/for-you", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.track(r, "for-you")
|
||||
const limit = 30
|
||||
rows, err := storage.ForYou(u.Sub, limit)
|
||||
if err != nil {
|
||||
slog.Error("web: for-you page failed", "sub", u.Sub, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
views := make([]StoryView, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
views = append(views, toView(row))
|
||||
}
|
||||
base := s.base(r)
|
||||
base.Active = "for-you"
|
||||
s.render(w, "for-you", forYouPage{pageData: base, Stories: views})
|
||||
}
|
||||
|
||||
func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channel) {
|
||||
s.track(r, ch.Slug)
|
||||
page := 1
|
||||
@@ -220,6 +274,64 @@ func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channe
|
||||
s.render(w, "channel", data)
|
||||
}
|
||||
|
||||
type bookmarksPage struct {
|
||||
pageData
|
||||
Stories []StoryView
|
||||
Page int
|
||||
HasPrev bool
|
||||
HasNext bool
|
||||
PrevURL string
|
||||
NextURL string
|
||||
Total int
|
||||
}
|
||||
|
||||
// handleBookmarks lists the signed-in user's bookmarked stories. Anonymous
|
||||
// visitors are sent to sign-in (the route is only registered when auth is on).
|
||||
func (s *Server) handleBookmarks(w http.ResponseWriter, r *http.Request) {
|
||||
u := s.auth.userFromRequest(r)
|
||||
if u == nil {
|
||||
http.Redirect(w, r, "/auth/login?next=/bookmarks", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.track(r, "bookmarks")
|
||||
page := 1
|
||||
if p := r.URL.Query().Get("page"); p != "" {
|
||||
if n, err := strconv.Atoi(p); err == nil && n > 0 {
|
||||
page = n
|
||||
}
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
rows, err := storage.ListBookmarks(u.Sub, pageSize+1, offset)
|
||||
if err != nil {
|
||||
slog.Error("web: list bookmarks failed", "sub", u.Sub, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
hasNext := len(rows) > pageSize
|
||||
if hasNext {
|
||||
rows = rows[:pageSize]
|
||||
}
|
||||
views := make([]StoryView, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
views = append(views, toView(row))
|
||||
}
|
||||
total, _ := storage.CountBookmarks(u.Sub)
|
||||
|
||||
base := s.base(r)
|
||||
base.Active = "bookmarks"
|
||||
data := bookmarksPage{
|
||||
pageData: base,
|
||||
Stories: views,
|
||||
Page: page,
|
||||
HasPrev: page > 1,
|
||||
HasNext: hasNext,
|
||||
PrevURL: fmt.Sprintf("/bookmarks?page=%d", page-1),
|
||||
NextURL: fmt.Sprintf("/bookmarks?page=%d", page+1),
|
||||
Total: total,
|
||||
}
|
||||
s.render(w, "bookmarks", data)
|
||||
}
|
||||
|
||||
var (
|
||||
demoVariants = []string{"rain", "petals", "jacaranda", "motes", "leaves", "clear", "clouds", "snow", "fog", "storm"}
|
||||
demoIntensities = []string{"light", "medium", "heavy"}
|
||||
@@ -309,6 +421,12 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"results": toSearchResults(rows)})
|
||||
}
|
||||
|
||||
// toSearchResults maps stories to the JSON shape shared by /api/search and
|
||||
// /api/related, resolving each story's channel to its display metadata.
|
||||
func toSearchResults(rows []storage.Story) []searchResult {
|
||||
channelByName := make(map[string]Channel, len(channels))
|
||||
for _, ch := range channels {
|
||||
channelByName[ch.Slug] = ch
|
||||
@@ -333,7 +451,50 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
Posted: row.Posted,
|
||||
})
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"results": results})
|
||||
return results
|
||||
}
|
||||
|
||||
// handleRelated returns stories textually similar to a given story, for the
|
||||
// "You might also like" rail in reader mode. It is public (reader mode works
|
||||
// for anonymous visitors too); for signed-in users it drops already-read
|
||||
// stories so recommendations stay fresh.
|
||||
func (s *Server) handleRelated(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("id")), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
http.Error(w, `{"error":"bad id"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
const relatedLimit = 6
|
||||
// Over-fetch so dropping already-read stories doesn't starve the rail.
|
||||
rows, err := storage.RelatedStories(id, relatedLimit*2)
|
||||
if err != nil {
|
||||
slog.Error("web: related failed", "id", id, "err", err)
|
||||
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if s.auth != nil && len(rows) > 0 {
|
||||
if u := s.auth.userFromRequest(r); u != nil {
|
||||
ids := make([]int64, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
ids = append(ids, row.ID)
|
||||
}
|
||||
if read, _, err := storage.UserStoryState(u.Sub, ids); err == nil {
|
||||
kept := rows[:0]
|
||||
for _, row := range rows {
|
||||
if !read[row.ID] {
|
||||
kept = append(kept, row)
|
||||
}
|
||||
}
|
||||
rows = kept
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(rows) > relatedLimit {
|
||||
rows = rows[:relatedLimit]
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"results": toSearchResults(rows)})
|
||||
}
|
||||
|
||||
// handleArticle serves the stored full text of a single story for reader mode.
|
||||
|
||||
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)
|
||||
}
|
||||
58
internal/web/push_sender_test.go
Normal file
58
internal/web/push_sender_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
func TestDisabledSourcesFor(t *testing.T) {
|
||||
storage.Close()
|
||||
if err := storage.Init(filepath.Join(t.TempDir(), "push.db")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { storage.Close() })
|
||||
|
||||
// The stored blob mirrors localStorage: the disabledSources value is itself a
|
||||
// JSON *string* encoding a {source: true} map (see prefs.js snapshot()).
|
||||
blob := `{"pete.disabledSources.v1":"{\"Feed A\":true,\"Feed B\":false}","pete.weather.loc.v1":"1000-100"}`
|
||||
if err := storage.PutUserPrefs("sub-1", blob, "u", "u@x"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := disabledSourcesFor("sub-1")
|
||||
if !got["Feed A"] {
|
||||
t.Error("Feed A should be disabled")
|
||||
}
|
||||
if got["Feed B"] {
|
||||
t.Error("Feed B is false in prefs and must not be treated as disabled")
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Errorf("disabled set = %v, want just {Feed A}", got)
|
||||
}
|
||||
|
||||
// A user with no prefs disables nothing.
|
||||
if len(disabledSourcesFor("nobody")) != 0 {
|
||||
t.Error("unknown user should have an empty disabled set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDigestPayload(t *testing.T) {
|
||||
single := string(buildDigestPayload(1, "Only Story"))
|
||||
if !strings.Contains(single, "1 new story") || strings.Contains(single, "1 new stories") {
|
||||
t.Errorf("singular wording wrong: %s", single)
|
||||
}
|
||||
if !strings.Contains(single, "Only Story") {
|
||||
t.Errorf("top headline missing: %s", single)
|
||||
}
|
||||
|
||||
many := string(buildDigestPayload(7, "Big One"))
|
||||
if !strings.Contains(many, "7 new stories") || !strings.Contains(many, "Big One") {
|
||||
t.Errorf("plural payload wrong: %s", many)
|
||||
}
|
||||
if !strings.Contains(many, `"tag":"pete-digest"`) {
|
||||
t.Errorf("expected digest tag in payload: %s", many)
|
||||
}
|
||||
}
|
||||
106
internal/web/pwa.go
Normal file
106
internal/web/pwa.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// maxPushBodyBytes caps a subscription payload. A PushSubscription JSON is an
|
||||
// endpoint URL plus two short base64 keys — a few hundred bytes — so 4 KiB is
|
||||
// generous headroom for long endpoint URLs.
|
||||
const maxPushBodyBytes = 4096
|
||||
|
||||
// handlePushSubscribe stores the caller's Web Push subscription. The body is the
|
||||
// browser's PushSubscription.toJSON() shape: {endpoint, keys:{p256dh, auth}}.
|
||||
func (s *Server) handlePushSubscribe(w http.ResponseWriter, r *http.Request) {
|
||||
u := s.requireUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
if !s.cfg.Push.Enabled {
|
||||
http.Error(w, `{"error":"push disabled"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
Keys struct {
|
||||
P256dh string `json:"p256dh"`
|
||||
Auth string `json:"auth"`
|
||||
} `json:"keys"`
|
||||
}
|
||||
if !decodeStateBodyN(w, r, &req, maxPushBodyBytes) {
|
||||
return
|
||||
}
|
||||
if req.Endpoint == "" || req.Keys.P256dh == "" || req.Keys.Auth == "" {
|
||||
http.Error(w, `{"error":"incomplete subscription"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := storage.AddPushSubscription(u.Sub, req.Endpoint, req.Keys.P256dh, req.Keys.Auth); err != nil {
|
||||
slog.Error("push: subscribe failed", "sub", u.Sub, "err", err)
|
||||
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handlePushUnsubscribe drops a stored subscription by endpoint. It doesn't
|
||||
// require the endpoint to belong to the caller beyond being signed in; the
|
||||
// endpoint is an unguessable capability URL, and dropping a stale one is benign.
|
||||
func (s *Server) handlePushUnsubscribe(w http.ResponseWriter, r *http.Request) {
|
||||
u := s.requireUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
}
|
||||
if !decodeStateBodyN(w, r, &req, maxPushBodyBytes) {
|
||||
return
|
||||
}
|
||||
if req.Endpoint == "" {
|
||||
http.Error(w, `{"error":"missing endpoint"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := storage.RemovePushSubscription(req.Endpoint); err != nil {
|
||||
slog.Error("push: unsubscribe failed", "sub", u.Sub, "err", err)
|
||||
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleManifest serves the web app manifest from the embedded static tree. It
|
||||
// lives at the root so the installable scope covers the whole origin.
|
||||
func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) {
|
||||
s.serveEmbedded(w, r, "manifest.webmanifest", "application/manifest+json; charset=utf-8", "public, max-age=3600")
|
||||
}
|
||||
|
||||
// handleServiceWorker serves /sw.js from the root. Serving it here rather than
|
||||
// under /static/ lets its scope be the whole origin (a worker's default scope
|
||||
// is its own path), and we set Service-Worker-Allowed as a belt-and-braces in
|
||||
// case it's ever moved. no-cache keeps updated workers from being pinned by the
|
||||
// HTTP cache — the browser still byte-compares to decide whether to install.
|
||||
func (s *Server) handleServiceWorker(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Service-Worker-Allowed", "/")
|
||||
s.serveEmbedded(w, r, "sw.js", "text/javascript; charset=utf-8", "no-cache")
|
||||
}
|
||||
|
||||
// serveEmbedded writes a file from the embedded static FS with explicit headers.
|
||||
func (s *Server) serveEmbedded(w http.ResponseWriter, _ *http.Request, name, contentType, cacheControl string) {
|
||||
sub, err := fs.Sub(staticFS, "static")
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
b, err := fs.ReadFile(sub, name)
|
||||
if err != nil {
|
||||
http.NotFound(w, nil)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Cache-Control", cacheControl)
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
@@ -42,7 +42,7 @@ func TestReaderCardDataAndArticleAPI(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil)
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -52,11 +52,13 @@ type SourceInfo struct {
|
||||
|
||||
// Server is the HTTP server for Pete's web UI.
|
||||
type Server struct {
|
||||
cfg config.WebConfig
|
||||
sources []SourceInfo
|
||||
srv *http.Server
|
||||
tpls map[string]*template.Template // keyed by page name (e.g. "index", "channel")
|
||||
auth *Authenticator // nil when sign-in is disabled or unavailable
|
||||
cfg config.WebConfig
|
||||
sources []SourceInfo
|
||||
postingEnabled bool // false = web-only mode; hides Matrix-posting UI
|
||||
srv *http.Server
|
||||
tpls map[string]*template.Template // keyed by page name (e.g. "index", "channel")
|
||||
auth *Authenticator // nil when sign-in is disabled or unavailable
|
||||
adminSubs map[string]bool // OIDC subjects allowed to view /status
|
||||
|
||||
// Daily-rotated salt for the privacy-preserving unique-visitor estimate.
|
||||
// Guarded by metricsMu; never persisted (see metrics.go).
|
||||
@@ -68,8 +70,8 @@ type Server struct {
|
||||
// New builds the server. Templates are parsed once at startup. Each page
|
||||
// gets its own template set sharing layout.html + _card.html, which avoids
|
||||
// `main` define collisions between pages.
|
||||
func New(cfg config.WebConfig, sources []config.SourceConfig) (*Server, error) {
|
||||
pages := []string{"index", "channel", "weather"}
|
||||
func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled bool) (*Server, error) {
|
||||
pages := []string{"index", "channel", "weather", "bookmarks", "for-you", "status"}
|
||||
tpls := make(map[string]*template.Template, len(pages))
|
||||
for _, p := range pages {
|
||||
t, err := template.New("").Funcs(funcs).ParseFS(templateFS,
|
||||
@@ -89,7 +91,13 @@ func New(cfg config.WebConfig, sources []config.SourceConfig) (*Server, error) {
|
||||
}
|
||||
infos = append(infos, SourceInfo{Name: sc.Name, Channel: sc.DirectRoute})
|
||||
}
|
||||
s := &Server{cfg: cfg, sources: infos, tpls: tpls}
|
||||
adminSubs := make(map[string]bool, len(cfg.AdminSubs))
|
||||
for _, sub := range cfg.AdminSubs {
|
||||
if sub != "" {
|
||||
adminSubs[sub] = true
|
||||
}
|
||||
}
|
||||
s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs}
|
||||
|
||||
// Optional OIDC sign-in (Authentik). Discovery is a network call; if the
|
||||
// provider is unreachable at boot we log and serve anonymously rather than
|
||||
@@ -115,15 +123,23 @@ func New(cfg config.WebConfig, sources []config.SourceConfig) (*Server, error) {
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
|
||||
mux.HandleFunc("GET /img/{name}", s.handleImg)
|
||||
|
||||
mux.HandleFunc("GET /manifest.webmanifest", s.handleManifest)
|
||||
mux.HandleFunc("GET /sw.js", s.handleServiceWorker)
|
||||
|
||||
mux.HandleFunc("GET /{$}", s.handleIndex)
|
||||
mux.HandleFunc("GET /weather", s.handleWeatherDemo)
|
||||
mux.HandleFunc("GET /api/search", s.handleSearch)
|
||||
mux.HandleFunc("GET /api/article", s.handleArticle)
|
||||
mux.HandleFunc("GET /api/related", s.handleRelated)
|
||||
mux.HandleFunc("GET /feed.xml", func(w http.ResponseWriter, r *http.Request) { s.handleFeedXML(w, r, "") })
|
||||
mux.HandleFunc("GET /feed.json", func(w http.ResponseWriter, r *http.Request) { s.handleFeedJSON(w, r, "") })
|
||||
for _, ch := range channels {
|
||||
ch := ch
|
||||
mux.HandleFunc("GET /"+ch.Slug, func(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleChannel(w, r, ch)
|
||||
})
|
||||
mux.HandleFunc("GET /"+ch.Slug+"/feed.xml", func(w http.ResponseWriter, r *http.Request) { s.handleFeedXML(w, r, ch.Slug) })
|
||||
mux.HandleFunc("GET /"+ch.Slug+"/feed.json", func(w http.ResponseWriter, r *http.Request) { s.handleFeedJSON(w, r, ch.Slug) })
|
||||
}
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -136,6 +152,16 @@ func New(cfg config.WebConfig, sources []config.SourceConfig) (*Server, error) {
|
||||
mux.HandleFunc("GET /auth/logout", s.auth.handleLogout)
|
||||
mux.HandleFunc("GET /api/preferences", s.handlePrefs)
|
||||
mux.HandleFunc("PUT /api/preferences", s.handlePrefs)
|
||||
mux.HandleFunc("POST /api/read", s.handleRead)
|
||||
mux.HandleFunc("POST /api/bookmark", s.handleBookmark)
|
||||
mux.HandleFunc("GET /api/state", s.handleState)
|
||||
mux.HandleFunc("GET /bookmarks", s.handleBookmarks)
|
||||
mux.HandleFunc("GET /for-you", s.handleForYou)
|
||||
mux.HandleFunc("GET /status", s.handleStatus)
|
||||
if s.cfg.Push.Enabled {
|
||||
mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
|
||||
mux.HandleFunc("POST /api/push/unsubscribe", s.handlePushUnsubscribe)
|
||||
}
|
||||
}
|
||||
|
||||
s.srv = &http.Server{
|
||||
|
||||
@@ -330,6 +330,62 @@ html[data-phase="night"] {
|
||||
.pete-reader-hint { display: none; }
|
||||
}
|
||||
|
||||
/* "You might also like" rail, shown under the article in feed mode. Lives
|
||||
inside the reader's scroll area, below the article card. */
|
||||
.pete-reader-related { width: 100%; margin: 0.85rem auto 0; }
|
||||
.pete-reader-related-title {
|
||||
font-family: "Fredoka", "Nunito", system-ui, sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
color: #fff;
|
||||
margin: 0 0 0.6rem;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.pete-reader-related-grid { display: grid; gap: 0.6rem; }
|
||||
.pete-reader-related-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background: var(--card);
|
||||
color: var(--ink);
|
||||
border-radius: 1rem;
|
||||
border: 2px solid rgba(0, 0, 0, 0.06);
|
||||
padding: 0.6rem;
|
||||
text-decoration: none;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.pete-reader-related-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 16px rgba(60, 40, 20, 0.16);
|
||||
}
|
||||
html[data-phase="night"] .pete-reader-related-card { border-color: rgba(255, 255, 255, 0.08); }
|
||||
.pete-reader-related-thumb {
|
||||
width: 4.5rem;
|
||||
height: 3.25rem;
|
||||
flex-shrink: 0;
|
||||
object-fit: cover;
|
||||
border-radius: 0.6rem;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.pete-reader-related-meta { min-width: 0; display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.pete-reader-related-eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.pete-reader-related-source { font-weight: 700; opacity: 0.7; }
|
||||
.pete-reader-related-headline {
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
font-size: 0.92rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Grid treatment for stories already read in feed mode: dimmed, with a small
|
||||
corner check. Hovering restores full opacity so nothing feels lost. */
|
||||
[data-story-card][data-read="1"] { opacity: 0.5; transition: opacity 0.2s ease; }
|
||||
|
||||
File diff suppressed because one or more lines are too long
BIN
internal/web/static/img/apple-touch-icon.png
Normal file
BIN
internal/web/static/img/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
internal/web/static/img/icon-192.png
Normal file
BIN
internal/web/static/img/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
BIN
internal/web/static/img/icon-512.png
Normal file
BIN
internal/web/static/img/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 154 KiB |
BIN
internal/web/static/img/maskable-512.png
Normal file
BIN
internal/web/static/img/maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
116
internal/web/static/js/pwa.js
Normal file
116
internal/web/static/js/pwa.js
Normal file
@@ -0,0 +1,116 @@
|
||||
// PWA glue: register the service worker, and (for signed-in users on a
|
||||
// push-enabled server) drive the notification opt-in toggle in the settings
|
||||
// panel. Anonymous visitors still get the offline reader — only the push
|
||||
// controls are gated behind sign-in + a configured VAPID key.
|
||||
(function () {
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
|
||||
var CFG = window.PETE_PUSH || null; // { enabled, publicKey } or null
|
||||
var reg = null;
|
||||
|
||||
navigator.serviceWorker.register("/sw.js").then(function (r) {
|
||||
reg = r;
|
||||
if (canPush()) initPushUI();
|
||||
}).catch(function () {});
|
||||
|
||||
function canPush() {
|
||||
return !!(CFG && CFG.enabled && CFG.publicKey && window.PETE_USER &&
|
||||
"PushManager" in window && "Notification" in window);
|
||||
}
|
||||
|
||||
// ---- push subscription ----------------------------------------------------
|
||||
function urlBase64ToUint8Array(base64String) {
|
||||
var padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||
var base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
var raw = atob(base64);
|
||||
var out = new Uint8Array(raw.length);
|
||||
for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function currentSub() {
|
||||
if (!reg) return Promise.resolve(null);
|
||||
return reg.pushManager.getSubscription();
|
||||
}
|
||||
|
||||
function subscribe() {
|
||||
return reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(CFG.publicKey),
|
||||
}).then(function (sub) {
|
||||
return fetch("/api/push/subscribe", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(sub.toJSON()),
|
||||
credentials: "same-origin",
|
||||
}).then(function (res) {
|
||||
if (!res.ok) throw new Error("subscribe rejected");
|
||||
return sub;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function unsubscribe() {
|
||||
return currentSub().then(function (sub) {
|
||||
if (!sub) return;
|
||||
var endpoint = sub.endpoint;
|
||||
return sub.unsubscribe().then(function () {
|
||||
return fetch("/api/push/unsubscribe", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ endpoint: endpoint }),
|
||||
credentials: "same-origin",
|
||||
}).catch(function () {});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---- settings-panel toggle ------------------------------------------------
|
||||
function initPushUI() {
|
||||
var slot = document.querySelector("[data-push-section]");
|
||||
if (!slot) return;
|
||||
slot.hidden = false;
|
||||
|
||||
var btn = slot.querySelector("[data-push-toggle]");
|
||||
var note = slot.querySelector("[data-push-note]");
|
||||
if (!btn) return;
|
||||
var busy = false;
|
||||
|
||||
function paint(on, text) {
|
||||
btn.setAttribute("aria-pressed", on ? "true" : "false");
|
||||
btn.textContent = on ? "Notifications on" : "Turn on notifications";
|
||||
if (note && text != null) note.textContent = text;
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (Notification.permission === "denied") {
|
||||
btn.disabled = true;
|
||||
paint(false, "Notifications are blocked in your browser settings.");
|
||||
return;
|
||||
}
|
||||
currentSub().then(function (sub) {
|
||||
paint(!!sub, sub ? "You'll get a nudge when new stories land." : "Get a nudge when new stories land.");
|
||||
});
|
||||
}
|
||||
|
||||
btn.addEventListener("click", function () {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
btn.disabled = true;
|
||||
currentSub().then(function (sub) {
|
||||
if (sub) return unsubscribe().then(function () { paint(false, "Notifications off."); });
|
||||
return Notification.requestPermission().then(function (perm) {
|
||||
if (perm !== "granted") { paint(false, "Permission denied."); return; }
|
||||
return subscribe().then(function () { paint(true, "You're all set — new stories will nudge you."); });
|
||||
});
|
||||
}).catch(function () {
|
||||
paint(false, "Something went wrong. Try again.");
|
||||
}).finally(function () {
|
||||
busy = false;
|
||||
btn.disabled = Notification.permission === "denied";
|
||||
});
|
||||
});
|
||||
|
||||
refresh();
|
||||
}
|
||||
})();
|
||||
@@ -20,6 +20,15 @@
|
||||
var nextBtn = overlay.querySelector("[data-reader-next]");
|
||||
var closeBtn = overlay.querySelector("[data-reader-close]");
|
||||
var backdrop = overlay.querySelector("[data-reader-backdrop]");
|
||||
var readerBookmarkBtn = overlay.querySelector("[data-reader-bookmark]");
|
||||
var relatedEl = overlay.querySelector("[data-reader-related]");
|
||||
var relatedCache = {}; // id -> results array
|
||||
|
||||
// Signed-in users (Authentik) get read + bookmark state synced server-side;
|
||||
// window.PETE_USER is non-null for them. Anonymous visitors keep the
|
||||
// localStorage-only behaviour and never see the bookmark controls.
|
||||
var SIGNED_IN = !!(window.PETE_USER);
|
||||
var bookmarkSet = Object.create(null); // id -> 1 for bookmarked stories
|
||||
|
||||
var items = []; // {id, url, headline, lede, image, time, source, chTitle, chEmoji, chTheme, posted, paywalled}
|
||||
var index = 0;
|
||||
@@ -45,6 +54,102 @@
|
||||
if (on) readSet[id] = 1; else delete readSet[id];
|
||||
saveRead(readSet);
|
||||
paintCard(id, on);
|
||||
if (SIGNED_IN) postState("/api/read", { id: Number(id), read: !!on });
|
||||
}
|
||||
|
||||
// ---- server sync (signed-in only) -----------------------------------------
|
||||
function postState(url, body) {
|
||||
try {
|
||||
fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
credentials: "same-origin",
|
||||
keepalive: true
|
||||
}).catch(function () {});
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function isBookmarked(id) { return !!bookmarkSet[id]; }
|
||||
|
||||
// setBookmark updates memory, paints every matching control, and persists.
|
||||
function setBookmark(id, on) {
|
||||
if (on) bookmarkSet[id] = 1; else delete bookmarkSet[id];
|
||||
paintBookmark(id, on);
|
||||
postState("/api/bookmark", { id: Number(id), on: !!on });
|
||||
// On the bookmarks page, an un-bookmark should drop the card immediately.
|
||||
if (!on && location.pathname === "/bookmarks") {
|
||||
document.querySelectorAll('[data-story-card][data-id="' + cssEsc(id) + '"]').forEach(function (c) {
|
||||
c.parentNode && c.parentNode.removeChild(c);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// setBookmarkQuiet applies server-provided state without echoing it back.
|
||||
function setBookmarkQuiet(id, on) {
|
||||
if (on) bookmarkSet[id] = 1; else delete bookmarkSet[id];
|
||||
paintBookmark(id, on);
|
||||
}
|
||||
|
||||
function paintBookmark(id, on) {
|
||||
document.querySelectorAll('[data-bookmark-btn][data-story-id="' + cssEsc(id) + '"]').forEach(function (b) {
|
||||
applyCardBookmark(b, on);
|
||||
});
|
||||
if (readerBookmarkBtn && items[index] && String(items[index].id) === String(id)) {
|
||||
applyReaderBookmark(on);
|
||||
}
|
||||
}
|
||||
|
||||
function applyCardBookmark(btn, on) {
|
||||
btn.setAttribute("aria-pressed", on ? "true" : "false");
|
||||
var svg = btn.querySelector("svg");
|
||||
if (on) {
|
||||
btn.style.background = "var(--accent)";
|
||||
btn.style.color = "#1c1305";
|
||||
if (svg) svg.setAttribute("fill", "currentColor");
|
||||
} else {
|
||||
btn.style.background = "rgba(20,14,6,.62)";
|
||||
btn.style.color = "#fff";
|
||||
if (svg) svg.setAttribute("fill", "none");
|
||||
}
|
||||
}
|
||||
|
||||
function applyReaderBookmark(on) {
|
||||
if (!readerBookmarkBtn) return;
|
||||
readerBookmarkBtn.setAttribute("aria-pressed", on ? "true" : "false");
|
||||
readerBookmarkBtn.textContent = on ? "🔖 Saved" : "🔖 Save";
|
||||
}
|
||||
|
||||
// initUserState reveals the bookmark controls and pulls the signed-in user's
|
||||
// read + bookmark state for the stories on this page, painting them and
|
||||
// migrating any device-local reads the account doesn't have yet.
|
||||
function initUserState() {
|
||||
if (!SIGNED_IN) return;
|
||||
document.querySelectorAll("[data-bookmark-btn]").forEach(function (b) {
|
||||
b.style.display = "inline-flex";
|
||||
});
|
||||
var ids = [];
|
||||
document.querySelectorAll("[data-story-card]").forEach(function (c) {
|
||||
var id = c.getAttribute("data-id");
|
||||
if (id) ids.push(id);
|
||||
});
|
||||
if (!ids.length) return;
|
||||
fetch("/api/state?ids=" + encodeURIComponent(ids.join(",")), { credentials: "same-origin" })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (data) {
|
||||
if (!data) return;
|
||||
var serverRead = Object.create(null);
|
||||
(data.read || []).forEach(function (id) {
|
||||
serverRead[id] = 1; readSet[id] = 1; paintCard(id, true);
|
||||
});
|
||||
(data.bookmarked || []).forEach(function (id) { setBookmarkQuiet(id, true); });
|
||||
// Push up reads made on this device before the account knew them.
|
||||
ids.forEach(function (id) {
|
||||
if (readSet[id] && !serverRead[id]) postState("/api/read", { id: Number(id), read: true });
|
||||
});
|
||||
saveRead(readSet);
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
// Reflect read state onto every matching card on the page (a story can appear
|
||||
// in more than one section on the home page).
|
||||
@@ -173,6 +278,61 @@
|
||||
.finally(function () { if (ctrl === inflight) inflight = null; });
|
||||
}
|
||||
|
||||
// ---- related ("you might also like") --------------------------------------
|
||||
function clearRelated() {
|
||||
if (!relatedEl) return;
|
||||
relatedEl.innerHTML = "";
|
||||
relatedEl.hidden = true;
|
||||
}
|
||||
|
||||
function renderRelated(reqId, results) {
|
||||
if (!relatedEl) return;
|
||||
// Ignore a response that arrived after the user moved on.
|
||||
if (!items[index] || String(items[index].id) !== String(reqId)) return;
|
||||
if (!results || !results.length) { clearRelated(); return; }
|
||||
var html = '<h2 class="pete-reader-related-title">You might also like</h2>' +
|
||||
'<div class="pete-reader-related-grid">';
|
||||
for (var i = 0; i < results.length; i++) {
|
||||
var it = results[i];
|
||||
var href = safeURL(it.article_url);
|
||||
if (!href) continue;
|
||||
var chip = it.channel_theme
|
||||
? '<span class="pete-reader-chip bg-theme-' + escapeHTML(it.channel_theme) + '">' +
|
||||
(it.channel_emoji ? '<span aria-hidden="true">' + escapeHTML(it.channel_emoji) + "</span>" : "") +
|
||||
escapeHTML(it.channel_title) + "</span>"
|
||||
: "";
|
||||
var thumb = it.thumb_url
|
||||
? '<img class="pete-reader-related-thumb" src="' + escapeHTML(it.thumb_url) + '" alt="" loading="lazy" decoding="async">'
|
||||
: '<div class="pete-reader-related-thumb pete-reader-related-thumb-empty"></div>';
|
||||
html += '<a class="pete-reader-related-card" href="' + escapeHTML(href) + '" target="_blank" rel="noopener noreferrer">' +
|
||||
thumb +
|
||||
'<div class="pete-reader-related-meta">' +
|
||||
'<div class="pete-reader-related-eyebrow">' + chip +
|
||||
(it.source ? '<span class="pete-reader-related-source">' + escapeHTML(it.source) + "</span>" : "") +
|
||||
"</div>" +
|
||||
'<div class="pete-reader-related-headline">' + escapeHTML(it.headline) + "</div>" +
|
||||
"</div></a>";
|
||||
}
|
||||
html += "</div>";
|
||||
relatedEl.innerHTML = html;
|
||||
relatedEl.hidden = false;
|
||||
}
|
||||
|
||||
function fetchRelated(it) {
|
||||
if (!relatedEl) return;
|
||||
clearRelated();
|
||||
var reqId = it.id;
|
||||
if (relatedCache[reqId]) { renderRelated(reqId, relatedCache[reqId]); return; }
|
||||
fetch("/api/related?id=" + encodeURIComponent(it.id), { credentials: "same-origin" })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (data) {
|
||||
var results = (data && data.results) || [];
|
||||
relatedCache[reqId] = results;
|
||||
renderRelated(reqId, results);
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
// ---- navigation -----------------------------------------------------------
|
||||
function show(i) {
|
||||
index = i;
|
||||
@@ -186,11 +346,17 @@
|
||||
nextBtn.disabled = false;
|
||||
nextBtn.textContent = index === items.length - 1 ? "done ✓" : "→";
|
||||
if (scrollEl) scrollEl.scrollTop = 0;
|
||||
if (readerBookmarkBtn) {
|
||||
readerBookmarkBtn.style.display = SIGNED_IN ? "" : "none";
|
||||
applyReaderBookmark(isBookmarked(it.id));
|
||||
}
|
||||
fetchContent(it);
|
||||
fetchRelated(it);
|
||||
setRead(it.id, true); // presenting a story marks it read
|
||||
}
|
||||
|
||||
function renderDone() {
|
||||
clearRelated();
|
||||
progressEl.textContent = items.length + " / " + items.length;
|
||||
linkEl.style.display = "none";
|
||||
prevBtn.disabled = items.length === 0;
|
||||
@@ -226,6 +392,7 @@
|
||||
function closeReader() {
|
||||
open = false;
|
||||
if (inflight) { inflight.abort(); inflight = null; }
|
||||
clearRelated();
|
||||
overlay.classList.add("hidden");
|
||||
document.body.classList.remove("overflow-hidden");
|
||||
}
|
||||
@@ -245,6 +412,32 @@
|
||||
if (nextBtn) nextBtn.addEventListener("click", next);
|
||||
if (closeBtn) closeBtn.addEventListener("click", closeReader);
|
||||
if (backdrop) backdrop.addEventListener("click", closeReader);
|
||||
if (readerBookmarkBtn) readerBookmarkBtn.addEventListener("click", function () {
|
||||
var it = items[index];
|
||||
if (it) setBookmark(it.id, !isBookmarked(it.id));
|
||||
});
|
||||
|
||||
initUserState();
|
||||
});
|
||||
|
||||
// Bookmark buttons live inside the card's <a>; intercept so a tap toggles the
|
||||
// bookmark instead of following the link. Delegated so it also covers cards
|
||||
// that are added or removed after load.
|
||||
document.addEventListener("click", function (e) {
|
||||
var btn = e.target.closest && e.target.closest("[data-bookmark-btn]");
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
var id = btn.getAttribute("data-story-id");
|
||||
if (id) setBookmark(id, !isBookmarked(id));
|
||||
});
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (e.key !== "Enter" && e.key !== " ") return;
|
||||
var btn = e.target.closest && e.target.closest("[data-bookmark-btn]");
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
var id = btn.getAttribute("data-story-id");
|
||||
if (id) setBookmark(id, !isBookmarked(id));
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", function (e) {
|
||||
@@ -274,6 +467,12 @@
|
||||
if (cur) setRead(cur.id, false); // let the user undo an accidental read
|
||||
break;
|
||||
}
|
||||
case "b": case "B": {
|
||||
if (!SIGNED_IN) break;
|
||||
var it = items[index];
|
||||
if (it) { e.preventDefault(); setBookmark(it.id, !isBookmarked(it.id)); }
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
22
internal/web/static/manifest.webmanifest
Normal file
22
internal/web/static/manifest.webmanifest
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "Pete — friendly news",
|
||||
"short_name": "Pete",
|
||||
"description": "A calm, read-one-at-a-time news reader. Bookmarks, a personalized feed, and offline reading.",
|
||||
"id": "/",
|
||||
"start_url": "/?source=pwa",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait-primary",
|
||||
"background_color": "#fbf3e3",
|
||||
"theme_color": "#fbf3e3",
|
||||
"categories": ["news", "productivity"],
|
||||
"icons": [
|
||||
{ "src": "/static/img/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/static/img/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/static/img/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
],
|
||||
"shortcuts": [
|
||||
{ "name": "For you", "short_name": "For you", "url": "/for-you", "description": "Your personalized feed" },
|
||||
{ "name": "Bookmarks", "short_name": "Saved", "url": "/bookmarks", "description": "Stories you saved" }
|
||||
]
|
||||
}
|
||||
198
internal/web/static/sw.js
Normal file
198
internal/web/static/sw.js
Normal file
@@ -0,0 +1,198 @@
|
||||
// Pete's service worker: an installable-PWA shell, an offline reader, and the
|
||||
// Web Push receiver. Served from the root (/sw.js) so its scope is the whole
|
||||
// origin — it can intercept navigations and /api/article the same as any page.
|
||||
//
|
||||
// Bump CACHE_VERSION whenever the precached shell assets change; activate()
|
||||
// drops every cache that doesn't match the current version.
|
||||
var CACHE_VERSION = "v1";
|
||||
var SHELL_CACHE = "pete-shell-" + CACHE_VERSION;
|
||||
var RUNTIME_CACHE = "pete-runtime-" + CACHE_VERSION;
|
||||
|
||||
// App shell: the static assets every page needs. Versioned by URL content only
|
||||
// loosely, so we lean on network-first for HTML and cache-first for these.
|
||||
var SHELL_ASSETS = [
|
||||
"/static/css/output.css",
|
||||
"/static/js/prefs.js",
|
||||
"/static/js/weather.js",
|
||||
"/static/js/weather-forecast.js",
|
||||
"/static/js/search.js",
|
||||
"/static/js/settings.js",
|
||||
"/static/js/reader.js",
|
||||
"/static/js/pwa.js",
|
||||
"/static/img/pete.avif",
|
||||
"/static/img/icon-192.png",
|
||||
"/static/img/icon-512.png",
|
||||
];
|
||||
|
||||
// How many visited-article responses to keep for offline reading before the
|
||||
// oldest are evicted. Reader articles are small JSON blobs.
|
||||
var RUNTIME_MAX = 60;
|
||||
|
||||
self.addEventListener("install", function (event) {
|
||||
event.waitUntil(
|
||||
caches.open(SHELL_CACHE).then(function (cache) {
|
||||
// addAll is atomic-ish: if one asset 404s the whole install fails, so keep
|
||||
// this list to assets we know are served. Individual failures are tolerated
|
||||
// by falling back to per-asset puts.
|
||||
return Promise.all(
|
||||
SHELL_ASSETS.map(function (url) {
|
||||
return cache.add(url).catch(function () {});
|
||||
})
|
||||
);
|
||||
}).then(function () {
|
||||
return self.skipWaiting();
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", function (event) {
|
||||
event.waitUntil(
|
||||
caches.keys().then(function (keys) {
|
||||
return Promise.all(
|
||||
keys.map(function (k) {
|
||||
if (k !== SHELL_CACHE && k !== RUNTIME_CACHE) return caches.delete(k);
|
||||
})
|
||||
);
|
||||
}).then(function () {
|
||||
return self.clients.claim();
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// trimCache evicts the oldest entries once a runtime cache passes its cap.
|
||||
function trimCache(cacheName, max) {
|
||||
caches.open(cacheName).then(function (cache) {
|
||||
cache.keys().then(function (keys) {
|
||||
if (keys.length <= max) return;
|
||||
for (var i = 0; i < keys.length - max; i++) cache.delete(keys[i]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// A minimal offline page for navigations we have nothing cached for.
|
||||
function offlineFallback() {
|
||||
return new Response(
|
||||
"<!doctype html><meta charset=utf-8><meta name=viewport content='width=device-width,initial-scale=1'>" +
|
||||
"<title>Offline · Pete</title>" +
|
||||
"<div style=\"font-family:system-ui,sans-serif;max-width:32rem;margin:20vh auto;padding:0 1.5rem;text-align:center;color:#3a2f1a\">" +
|
||||
"<div style=font-size:3rem>🦆</div>" +
|
||||
"<h1 style=font-size:1.4rem>You're offline</h1>" +
|
||||
"<p style=opacity:.7>Pete can't reach the news right now. Articles you've already opened are still readable — head back and try one of those.</p>" +
|
||||
"</div>",
|
||||
{ headers: { "Content-Type": "text/html; charset=utf-8" }, status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
self.addEventListener("fetch", function (event) {
|
||||
var req = event.request;
|
||||
if (req.method !== "GET") return;
|
||||
|
||||
var url = new URL(req.url);
|
||||
if (url.origin !== self.location.origin) return; // never touch cross-origin
|
||||
|
||||
// Visited articles: network-first so text stays fresh, but cache every success
|
||||
// so the reader still works offline for stories the user has already opened.
|
||||
if (url.pathname === "/api/article") {
|
||||
event.respondWith(
|
||||
fetch(req).then(function (res) {
|
||||
if (res && res.ok) {
|
||||
var copy = res.clone();
|
||||
caches.open(RUNTIME_CACHE).then(function (cache) {
|
||||
cache.put(req, copy);
|
||||
trimCache(RUNTIME_CACHE, RUNTIME_MAX);
|
||||
});
|
||||
}
|
||||
return res;
|
||||
}).catch(function () {
|
||||
return caches.match(req).then(function (hit) {
|
||||
return hit || new Response(JSON.stringify({ error: "offline" }), {
|
||||
status: 503,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Static assets: cache-first (they're versioned by deploy), fill the cache on
|
||||
// first miss so a later offline visit has them.
|
||||
if (url.pathname.indexOf("/static/") === 0) {
|
||||
event.respondWith(
|
||||
caches.match(req).then(function (hit) {
|
||||
return hit || fetch(req).then(function (res) {
|
||||
if (res && res.ok) {
|
||||
var copy = res.clone();
|
||||
caches.open(SHELL_CACHE).then(function (cache) { cache.put(req, copy); });
|
||||
}
|
||||
return res;
|
||||
});
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Page navigations: network-first, fall back to a cached copy of the same page,
|
||||
// then to the offline card. Successful HTML is cached so revisits work offline.
|
||||
if (req.mode === "navigate") {
|
||||
event.respondWith(
|
||||
fetch(req).then(function (res) {
|
||||
if (res && res.ok) {
|
||||
var copy = res.clone();
|
||||
caches.open(RUNTIME_CACHE).then(function (cache) {
|
||||
cache.put(req, copy);
|
||||
trimCache(RUNTIME_CACHE, RUNTIME_MAX);
|
||||
});
|
||||
}
|
||||
return res;
|
||||
}).catch(function () {
|
||||
return caches.match(req).then(function (hit) {
|
||||
return hit || offlineFallback();
|
||||
});
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Everything else (other /api/* calls): straight to the network. These are
|
||||
// personalized/stateful and must not be served stale.
|
||||
});
|
||||
|
||||
// ---- Web Push -------------------------------------------------------------
|
||||
// The server sends a JSON payload {title, body, url, tag}. Missing fields fall
|
||||
// back to sensible defaults so a malformed push still shows something useful.
|
||||
self.addEventListener("push", function (event) {
|
||||
var data = {};
|
||||
if (event.data) {
|
||||
try { data = event.data.json(); } catch (e) { data = { body: event.data.text() }; }
|
||||
}
|
||||
var title = data.title || "Pete";
|
||||
var options = {
|
||||
body: data.body || "New stories are waiting.",
|
||||
icon: "/static/img/icon-192.png",
|
||||
badge: "/static/img/icon-192.png",
|
||||
tag: data.tag || "pete-digest",
|
||||
renotify: true,
|
||||
data: { url: data.url || "/" },
|
||||
};
|
||||
event.waitUntil(self.registration.showNotification(title, options));
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", function (event) {
|
||||
event.notification.close();
|
||||
var target = (event.notification.data && event.notification.data.url) || "/";
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then(function (clients) {
|
||||
for (var i = 0; i < clients.length; i++) {
|
||||
var c = clients[i];
|
||||
// Focus an existing Pete tab and route it to the target if we can.
|
||||
if ("focus" in c) {
|
||||
c.focus();
|
||||
if ("navigate" in c && target !== "/") { try { c.navigate(target); } catch (e) {} }
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (self.clients.openWindow) return self.clients.openWindow(target);
|
||||
})
|
||||
);
|
||||
});
|
||||
128
internal/web/status.go
Normal file
128
internal/web/status.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// isAdmin reports whether the request carries a signed-in session whose OIDC
|
||||
// subject is on the admin allowlist. False when auth is off, the allowlist is
|
||||
// empty, or the visitor is anonymous.
|
||||
func (s *Server) isAdmin(r *http.Request) bool {
|
||||
if s.auth == nil || len(s.adminSubs) == 0 {
|
||||
return false
|
||||
}
|
||||
u := s.auth.userFromRequest(r)
|
||||
if u == nil {
|
||||
return false
|
||||
}
|
||||
return s.adminSubs[u.Sub]
|
||||
}
|
||||
|
||||
// sourceStatus is one row of the source-health dashboard: the configured feed
|
||||
// plus its persisted poll health and derived content stats.
|
||||
type sourceStatus struct {
|
||||
Name string
|
||||
Channel string
|
||||
Healthy bool // last poll succeeded (no consecutive failures)
|
||||
NeverRun bool // no poll recorded yet
|
||||
|
||||
LastPollAt time.Time
|
||||
LastSuccessAt time.Time
|
||||
LastError string
|
||||
Failures int
|
||||
LastItemCount int
|
||||
|
||||
Total int
|
||||
Classified int
|
||||
Paywalled int
|
||||
PaywallRate int // percent of retained stories that are gated
|
||||
LastSeenAt time.Time
|
||||
LastPostedAt time.Time
|
||||
}
|
||||
|
||||
type statusPage struct {
|
||||
pageData
|
||||
Sources []sourceStatus
|
||||
DegradedCnt int // sources currently failing
|
||||
}
|
||||
|
||||
// handleStatus renders the owner-facing source-health dashboard. Access is
|
||||
// restricted to admin subjects; everyone else gets a 404 so the page's
|
||||
// existence isn't advertised.
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.isAdmin(r) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.track(r, "status")
|
||||
|
||||
health, err := storage.ListSourceHealth()
|
||||
if err != nil {
|
||||
slog.Error("web: source health query failed", "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
stats, err := storage.SourceContentStats()
|
||||
if err != nil {
|
||||
slog.Error("web: source content stats failed", "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
rows := make([]sourceStatus, 0, len(s.sources))
|
||||
degraded := 0
|
||||
for _, src := range s.sources {
|
||||
h, hasHealth := health[src.Name]
|
||||
st := stats[src.Name]
|
||||
|
||||
row := sourceStatus{
|
||||
Name: src.Name,
|
||||
Channel: src.Channel,
|
||||
NeverRun: !hasHealth || h.LastPollAt == 0,
|
||||
LastError: h.LastError,
|
||||
Failures: h.ConsecutiveFailures,
|
||||
LastItemCount: h.LastItemCount,
|
||||
Total: st.Total,
|
||||
Classified: st.Classified,
|
||||
Paywalled: st.Paywalled,
|
||||
}
|
||||
row.Healthy = hasHealth && h.ConsecutiveFailures == 0
|
||||
if h.LastPollAt > 0 {
|
||||
row.LastPollAt = time.Unix(h.LastPollAt, 0)
|
||||
}
|
||||
if h.LastSuccessAt > 0 {
|
||||
row.LastSuccessAt = time.Unix(h.LastSuccessAt, 0)
|
||||
}
|
||||
if st.LastSeenAt > 0 {
|
||||
row.LastSeenAt = time.Unix(st.LastSeenAt, 0)
|
||||
}
|
||||
if st.LastPostedAt > 0 {
|
||||
row.LastPostedAt = time.Unix(st.LastPostedAt, 0)
|
||||
}
|
||||
if st.Total > 0 {
|
||||
row.PaywallRate = st.Paywalled * 100 / st.Total
|
||||
}
|
||||
if !row.NeverRun && !row.Healthy {
|
||||
degraded++
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
// Failing sources first (most consecutive failures), then healthy ones by
|
||||
// name, so the owner's eye lands on what needs attention.
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
if rows[i].Failures != rows[j].Failures {
|
||||
return rows[i].Failures > rows[j].Failures
|
||||
}
|
||||
return rows[i].Name < rows[j].Name
|
||||
})
|
||||
|
||||
base := s.base(r)
|
||||
base.Active = "status"
|
||||
s.render(w, "status", statusPage{pageData: base, Sources: rows, DegradedCnt: degraded})
|
||||
}
|
||||
38
internal/web/status_render_test.go
Normal file
38
internal/web/status_render_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pete/internal/config"
|
||||
)
|
||||
|
||||
func TestStatusTemplateExecutes(t *testing.T) {
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := statusPage{
|
||||
pageData: pageData{SiteTitle: "Pete", Channels: channels},
|
||||
DegradedCnt: 1,
|
||||
Sources: []sourceStatus{
|
||||
{Name: "Broken Feed", Channel: "tech", Failures: 3, LastError: "dial tcp: timeout",
|
||||
LastPollAt: time.Now(), NeverRun: false, Healthy: false, Total: 4, Paywalled: 2, PaywallRate: 50},
|
||||
{Name: "Good Feed", Channel: "eu", Healthy: true, LastPollAt: time.Now(),
|
||||
LastSuccessAt: time.Now(), LastItemCount: 12, Total: 30, Classified: 28,
|
||||
LastSeenAt: time.Now(), LastPostedAt: time.Now()},
|
||||
{Name: "Idle Feed", Channel: "music", NeverRun: true},
|
||||
},
|
||||
}
|
||||
var b strings.Builder
|
||||
if err := s.tpls["status"].ExecuteTemplate(&b, "layout", data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := b.String()
|
||||
for _, want := range []string{"Broken Feed", "Good Feed", "dial tcp: timeout", "1 degraded", "Source health"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("rendered status page missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,13 @@
|
||||
data-posted="{{if .Story.Posted}}1{{end}}"
|
||||
data-paywalled="{{if .Story.Paywalled}}1{{end}}"
|
||||
class="group relative block rounded-3xl bg-[color:var(--card)] border-2 {{if .Story.Posted}}border-theme-{{.Theme}} glow-theme-{{.Theme}}{{else}}border-[color:var(--ink)]/10{{end}} shadow-pete overflow-hidden hover:-translate-y-1 hover:shadow-pete-lg transition">
|
||||
<span role="button" tabindex="0" data-bookmark-btn data-story-id="{{.Story.ID}}"
|
||||
aria-label="Bookmark this story" aria-pressed="false"
|
||||
style="display:none;position:absolute;top:.6rem;left:.6rem;z-index:6;height:1.9rem;width:1.9rem;align-items:center;justify-content:center;border-radius:9999px;background:rgba(20,14,6,.62);color:#fff;-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px)">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M6 2h12a1 1 0 0 1 1 1v18l-7-4-7 4V3a1 1 0 0 1 1-1z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
{{if .Story.ImageURL}}
|
||||
<div class="aspect-[16/10] w-full overflow-hidden bg-[color:var(--ink)]/5">
|
||||
<img src="{{thumb .Story.ImageURL}}" alt="" loading="lazy" decoding="async"
|
||||
|
||||
37
internal/web/templates/bookmarks.html
Normal file
37
internal/web/templates/bookmarks.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{{define "title"}}Bookmarks — {{.SiteTitle}}{{end}}
|
||||
|
||||
{{define "main"}}
|
||||
<section class="mt-2 mb-8">
|
||||
<div class="rounded-3xl bg-[color:var(--accent)] text-[#1c1305] p-6 sm:p-10 shadow-pete relative overflow-hidden">
|
||||
<div class="absolute -top-6 -right-6 text-[12rem] opacity-20 select-none" aria-hidden="true">🔖</div>
|
||||
<div class="relative">
|
||||
<p class="text-sm uppercase tracking-[0.2em] opacity-70">saved for later</p>
|
||||
<h1 class="font-display text-4xl sm:text-5xl font-bold mt-1">Bookmarks</h1>
|
||||
<p class="mt-2 max-w-xl opacity-80">Stories you've tucked away. Tap the bookmark on any card to add or remove one.</p>
|
||||
<p class="mt-4 text-xs uppercase tracking-wider opacity-75">{{.Total}} saved</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{if .Stories}}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{{range .Stories}}
|
||||
{{template "card" dict "Story" . "Theme" .Channel "ShowChannel" true}}
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<nav class="mt-10 flex items-center justify-between">
|
||||
{{if .HasPrev}}
|
||||
<a href="{{.PrevURL}}" class="rounded-full bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 px-5 py-2 font-semibold shadow-pete hover:-translate-y-0.5 transition">← newer</a>
|
||||
{{else}}<span></span>{{end}}
|
||||
<span class="text-sm text-[color:var(--ink)]/60">page {{.Page}}</span>
|
||||
{{if .HasNext}}
|
||||
<a href="{{.NextURL}}" class="rounded-full bg-[color:var(--accent)] text-[#1c1305] px-5 py-2 font-semibold shadow-pete hover:-translate-y-0.5 transition">older →</a>
|
||||
{{else}}<span></span>{{end}}
|
||||
</nav>
|
||||
{{else}}
|
||||
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-dashed border-[color:var(--ink)]/15 p-10 text-center text-[color:var(--ink)]/60">
|
||||
Nothing saved yet. Browse the feed and tap the 🔖 on a story to keep it here.
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
26
internal/web/templates/for-you.html
Normal file
26
internal/web/templates/for-you.html
Normal file
@@ -0,0 +1,26 @@
|
||||
{{define "title"}}For you — {{.SiteTitle}}{{end}}
|
||||
|
||||
{{define "main"}}
|
||||
<section class="mt-2 mb-8">
|
||||
<div class="rounded-3xl bg-[color:var(--accent)] text-[#1c1305] p-6 sm:p-10 shadow-pete relative overflow-hidden">
|
||||
<div class="absolute -top-6 -right-6 text-[12rem] opacity-20 select-none" aria-hidden="true">✨</div>
|
||||
<div class="relative">
|
||||
<p class="text-sm uppercase tracking-[0.2em] opacity-70">picked for you</p>
|
||||
<h1 class="font-display text-4xl sm:text-5xl font-bold mt-1">For you</h1>
|
||||
<p class="mt-2 max-w-xl opacity-80">Fresh stories weighted toward the channels and sources you read and bookmark most. The more you read, the sharper this gets.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{if .Stories}}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{{range .Stories}}
|
||||
{{template "card" dict "Story" . "Theme" .Channel "ShowChannel" true}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-dashed border-[color:var(--ink)]/15 p-10 text-center text-[color:var(--ink)]/60">
|
||||
Nothing to tune yet. Read a few stories or bookmark the ones you like, and Pete will start building a feed around them.
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -6,14 +6,34 @@
|
||||
a friendlier news feed.
|
||||
</h1>
|
||||
<p class="mx-auto mt-3 max-w-xl text-base sm:text-lg text-[color:var(--ink)]/70">
|
||||
{{if .PostingEnabled}}
|
||||
Pete reads RSS, sorts stories, and posts them to Matrix.
|
||||
Glowing cards are the ones he's posted.
|
||||
{{else}}
|
||||
Pete reads RSS and sorts stories into channels, fresh from his feeds.
|
||||
{{end}}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section data-weather-card class="mb-10 empty:hidden"></section>
|
||||
|
||||
{{if .JustPosted}}
|
||||
{{if .ForYou}}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-end justify-between gap-3 mb-4">
|
||||
<h2 class="font-display text-xl sm:text-2xl font-bold">
|
||||
<span aria-hidden="true">✨</span> For you
|
||||
</h2>
|
||||
<a href="/for-you" class="text-xs font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)]">more →</a>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{{range .ForYou}}
|
||||
{{template "card" dict "Story" . "Theme" .Channel "ShowChannel" true}}
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if and .PostingEnabled .JustPosted}}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-end justify-between gap-3 mb-4">
|
||||
<h2 class="font-display text-xl sm:text-2xl font-bold">
|
||||
@@ -34,7 +54,7 @@
|
||||
<h2 class="font-display text-xl sm:text-2xl font-bold">
|
||||
<span aria-hidden="true">📊</span> Channels
|
||||
</h2>
|
||||
<span class="text-xs text-[color:var(--ink)]/50">last 24 hours</span>
|
||||
<span class="text-xs text-[color:var(--ink)]/50">{{if .PostingEnabled}}last 24 hours{{else}}what Pete's tracking{{end}}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
||||
{{range .Stats}}
|
||||
@@ -47,6 +67,7 @@
|
||||
<span class="font-display text-lg font-semibold">{{.Channel.Title}}</span>
|
||||
</div>
|
||||
<dl class="mt-3 space-y-1 text-xs text-[color:var(--ink)]/70">
|
||||
{{if $.PostingEnabled}}
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt>last post</dt>
|
||||
<dd class="font-semibold text-[color:var(--ink)]">
|
||||
@@ -57,6 +78,7 @@
|
||||
<dt>posted 24h</dt>
|
||||
<dd class="font-semibold text-[color:var(--ink)]">{{.PostsToday}}</dd>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt>stories</dt>
|
||||
<dd class="font-semibold text-[color:var(--ink)]">{{.Total}}</dd>
|
||||
|
||||
@@ -9,6 +9,17 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/output.css">
|
||||
<link rel="icon" href="/static/img/pete.avif" type="image/avif">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<meta name="theme-color" content="#fbf3e3">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="apple-mobile-web-app-title" content="Pete">
|
||||
<link rel="apple-touch-icon" href="/static/img/apple-touch-icon.png">
|
||||
<link rel="alternate" type="application/rss+xml" title="{{.SiteTitle}} — all stories" href="/feed.xml">
|
||||
<link rel="alternate" type="application/feed+json" title="{{.SiteTitle}} — all stories" href="/feed.json">
|
||||
{{if .Active}}{{if ne .Active "bookmarks"}}
|
||||
<link rel="alternate" type="application/rss+xml" title="{{.SiteTitle}} — {{.Active}}" href="/{{.Active}}/feed.xml">
|
||||
{{end}}{{end}}
|
||||
<script>
|
||||
// Pick a palette phase from the browser clock and update it each minute.
|
||||
(function () {
|
||||
@@ -79,6 +90,26 @@
|
||||
class="hidden shrink-0 items-center gap-1.5 rounded-full bg-[color:var(--card)] px-3 py-2 text-sm font-semibold shadow-pete border-2 border-[color:var(--ink)]/10 tabular-nums"></span>
|
||||
{{if .AuthEnabled}}
|
||||
{{if .User}}
|
||||
<a href="/bookmarks" data-bookmarks-link
|
||||
title="Your bookmarks"
|
||||
class="inline-flex shrink-0 items-center justify-center rounded-full bg-[color:var(--card)] p-2 shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition{{if eq .Active "bookmarks"}} bg-[color:var(--accent)]/20{{end}}">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5">
|
||||
<path d="M6 2h12a1 1 0 0 1 1 1v18l-7-4-7 4V3a1 1 0 0 1 1-1z"></path>
|
||||
</svg>
|
||||
<span class="sr-only">Bookmarks</span>
|
||||
</a>
|
||||
{{if .IsAdmin}}
|
||||
<a href="/status" data-status-link
|
||||
title="Source health"
|
||||
class="inline-flex shrink-0 items-center justify-center rounded-full bg-[color:var(--card)] p-2 shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition{{if eq .Active "status"}} bg-[color:var(--accent)]/20{{end}}">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5">
|
||||
<path d="M3 12h4l2 6 4-14 2 8h6"></path>
|
||||
</svg>
|
||||
<span class="sr-only">Source health</span>
|
||||
</a>
|
||||
{{end}}
|
||||
<a href="/auth/logout" data-account
|
||||
title="Signed in as {{.User.Display}}{{if .User.Email}} · {{.User.Email}}{{end}} — sign out"
|
||||
class="inline-flex shrink-0 items-center gap-2 rounded-full bg-[color:var(--card)] px-2.5 py-1.5 text-sm font-semibold shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
|
||||
@@ -112,6 +143,14 @@
|
||||
</button>
|
||||
<nav class="pete-channel-nav flex min-w-0 items-center gap-1 sm:gap-2 overflow-x-auto rounded-full bg-[color:var(--card)] p-1 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||
{{$active := .Active}}
|
||||
{{if .User}}
|
||||
<a href="/for-you"
|
||||
title="Your personalized feed"
|
||||
class="shrink-0 rounded-full px-3 py-2 text-sm font-semibold transition
|
||||
{{if eq $active "for-you"}}bg-[color:var(--accent)] text-[#1c1305] shadow-sm{{else}}hover:bg-[color:var(--ink)]/5{{end}}">
|
||||
<span aria-hidden="true" class="mr-1">✨</span><span class="hidden sm:inline">For you</span>
|
||||
</a>
|
||||
{{end}}
|
||||
{{range .Channels}}
|
||||
<a href="/{{.Slug}}"
|
||||
class="shrink-0 rounded-full px-3 py-2 text-sm font-semibold transition
|
||||
@@ -146,6 +185,20 @@
|
||||
<h2 class="font-display text-lg font-bold">Feed settings</h2>
|
||||
<button type="button" data-settings-close class="rounded-full px-2 py-1 text-sm text-[color:var(--ink)]/60 hover:bg-[color:var(--ink)]/5">esc</button>
|
||||
</div>
|
||||
{{if .PushEnabled}}{{if .User}}
|
||||
<div data-push-section hidden class="px-5 pt-4 pb-1">
|
||||
<div class="flex items-center justify-between gap-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-bold">🔔 Notifications</div>
|
||||
<div data-push-note class="text-xs text-[color:var(--ink)]/60">Get a nudge when new stories land.</div>
|
||||
</div>
|
||||
<button type="button" data-push-toggle aria-pressed="false"
|
||||
class="shrink-0 rounded-full bg-[color:var(--accent)] px-3 py-2 text-sm font-semibold text-[#1c1305] shadow-sm hover:brightness-105 transition disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
Turn on notifications
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}{{end}}
|
||||
<p class="px-5 pt-3 text-xs text-[color:var(--ink)]/60">Uncheck a feed to hide its stories. <span data-storage-note>Saved in this browser.</span></p>
|
||||
<div data-settings-list class="max-h-[55vh] overflow-y-auto p-3 space-y-1"></div>
|
||||
<div class="px-5 py-3 border-t border-[color:var(--ink)]/10 flex items-center justify-between text-xs">
|
||||
@@ -163,12 +216,14 @@
|
||||
<div class="pete-reader-nav">
|
||||
<button type="button" data-reader-prev class="pete-reader-btn" title="Previous (←)" aria-label="Previous article">←</button>
|
||||
<button type="button" data-reader-next class="pete-reader-btn" title="Next (→)" aria-label="Next article">→</button>
|
||||
<button type="button" data-reader-bookmark class="pete-reader-btn" style="display:none" title="Bookmark (b)" aria-label="Bookmark this story" aria-pressed="false">🔖 Save</button>
|
||||
<a data-reader-link target="_blank" rel="noopener noreferrer" class="pete-reader-btn pete-reader-btn-open" title="Open original (o)">Open ↗</a>
|
||||
<button type="button" data-reader-close class="pete-reader-btn" title="Close (esc)" aria-label="Close reader">esc</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pete-reader-scroll" data-reader-scroll>
|
||||
<article class="pete-reader-article" data-reader-article></article>
|
||||
<div class="pete-reader-related" data-reader-related hidden></div>
|
||||
</div>
|
||||
<div class="pete-reader-hint">
|
||||
<span><kbd>←</kbd> <kbd>→</kbd> navigate · <kbd>o</kbd> open · <kbd>u</kbd> mark unread · <kbd>esc</kbd> close</span>
|
||||
@@ -207,6 +262,7 @@
|
||||
window.PETE_SOURCES = {{.AllSources}};
|
||||
window.PETE_USER = {{if .User}}{ name: {{.User.Display}}, email: {{.User.Email}} }{{else}}null{{end}};
|
||||
window.PETE_PREFS = {{.UserPrefs}};
|
||||
window.PETE_PUSH = {{if .PushEnabled}}{ enabled: true, publicKey: {{.PushPublicKey}} }{{else}}null{{end}};
|
||||
</script>
|
||||
<script src="/static/js/prefs.js" defer></script>
|
||||
<script src="/static/js/weather.js" defer></script>
|
||||
@@ -214,5 +270,6 @@
|
||||
<script src="/static/js/search.js" defer></script>
|
||||
<script src="/static/js/settings.js" defer></script>
|
||||
<script src="/static/js/reader.js" defer></script>
|
||||
<script src="/static/js/pwa.js" defer></script>
|
||||
</body>
|
||||
</html>{{end}}
|
||||
|
||||
80
internal/web/templates/status.html
Normal file
80
internal/web/templates/status.html
Normal file
@@ -0,0 +1,80 @@
|
||||
{{define "title"}}Source health — {{.SiteTitle}}{{end}}
|
||||
|
||||
{{define "main"}}
|
||||
<section class="mt-2 mb-8">
|
||||
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 sm:p-8 shadow-pete">
|
||||
<p class="text-sm uppercase tracking-[0.2em] text-[color:var(--ink)]/50">owner view</p>
|
||||
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-1">Source health</h1>
|
||||
<p class="mt-2 max-w-2xl text-[color:var(--ink)]/70">
|
||||
Per-feed poll status and content stats. Rows that are currently failing float to the top.
|
||||
</p>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-[color:var(--ink)]/5 px-3 py-1 text-sm font-semibold tabular-nums">
|
||||
{{len .Sources}} sources
|
||||
</span>
|
||||
{{if .DegradedCnt}}
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-red-500/15 text-red-700 dark:text-red-300 px-3 py-1 text-sm font-semibold tabular-nums">
|
||||
⚠ {{.DegradedCnt}} degraded
|
||||
</span>
|
||||
{{else}}
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 px-3 py-1 text-sm font-semibold">
|
||||
✓ all healthy
|
||||
</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="overflow-x-auto rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete">
|
||||
<table class="w-full min-w-[52rem] text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-[color:var(--ink)]/50 uppercase text-xs tracking-wider border-b-2 border-[color:var(--ink)]/10">
|
||||
<th class="px-4 py-3 font-semibold">Source</th>
|
||||
<th class="px-4 py-3 font-semibold">Channel</th>
|
||||
<th class="px-4 py-3 font-semibold">Status</th>
|
||||
<th class="px-4 py-3 font-semibold">Last poll</th>
|
||||
<th class="px-4 py-3 font-semibold">Last success</th>
|
||||
<th class="px-4 py-3 font-semibold text-right">Items</th>
|
||||
<th class="px-4 py-3 font-semibold text-right">Stories</th>
|
||||
<th class="px-4 py-3 font-semibold text-right">Paywall</th>
|
||||
<th class="px-4 py-3 font-semibold">Last story</th>
|
||||
<th class="px-4 py-3 font-semibold">Last posted</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Sources}}
|
||||
<tr class="border-b border-[color:var(--ink)]/5 last:border-0 align-top">
|
||||
<td class="px-4 py-3 font-semibold whitespace-nowrap">{{.Name}}</td>
|
||||
<td class="px-4 py-3 text-[color:var(--ink)]/70 whitespace-nowrap">{{.Channel}}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
{{if .NeverRun}}
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-[color:var(--ink)]/10 px-2.5 py-1 text-xs font-semibold">◌ idle</span>
|
||||
{{else if .Healthy}}
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 px-2.5 py-1 text-xs font-semibold">● ok</span>
|
||||
{{else}}
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-red-500/15 text-red-700 dark:text-red-300 px-2.5 py-1 text-xs font-semibold"
|
||||
title="{{.LastError}}">✕ {{.Failures}} fail{{if ne .Failures 1}}s{{end}}</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-[color:var(--ink)]/70 whitespace-nowrap">{{if .LastPollAt.IsZero}}never{{else}}{{timeAgo .LastPollAt}}{{end}}</td>
|
||||
<td class="px-4 py-3 text-[color:var(--ink)]/70 whitespace-nowrap">{{if .LastSuccessAt.IsZero}}never{{else}}{{timeAgo .LastSuccessAt}}{{end}}</td>
|
||||
<td class="px-4 py-3 text-right tabular-nums">{{.LastItemCount}}</td>
|
||||
<td class="px-4 py-3 text-right tabular-nums" title="{{.Classified}} classified of {{.Total}}">{{.Total}}</td>
|
||||
<td class="px-4 py-3 text-right tabular-nums {{if gt .PaywallRate 50}}text-amber-600 dark:text-amber-400 font-semibold{{end}}">
|
||||
{{if .Total}}{{.PaywallRate}}%{{else}}—{{end}}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-[color:var(--ink)]/70 whitespace-nowrap">{{if .LastSeenAt.IsZero}}—{{else}}{{timeAgo .LastSeenAt}}{{end}}</td>
|
||||
<td class="px-4 py-3 text-[color:var(--ink)]/70 whitespace-nowrap">{{if .LastPostedAt.IsZero}}—{{else}}{{timeAgo .LastPostedAt}}{{end}}</td>
|
||||
</tr>
|
||||
{{if .LastError}}{{if not .Healthy}}
|
||||
<tr class="border-b border-[color:var(--ink)]/5">
|
||||
<td colspan="10" class="px-4 pb-3 -mt-1 text-xs text-red-600 dark:text-red-400 font-mono break-all">{{.LastError}}</td>
|
||||
</tr>
|
||||
{{end}}{{end}}
|
||||
{{else}}
|
||||
<tr><td colspan="10" class="px-4 py-8 text-center text-[color:var(--ink)]/50">No sources configured.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
159
internal/web/userstate_api.go
Normal file
159
internal/web/userstate_api.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// maxStateBodyBytes caps read/bookmark request bodies. These carry a single id
|
||||
// and a boolean, so this is generous headroom.
|
||||
const maxStateBodyBytes = 1024
|
||||
|
||||
// maxStateIDs bounds how many ids /api/state will look up in one call. A page
|
||||
// renders a few dozen cards; this is well clear of that.
|
||||
const maxStateIDs = 500
|
||||
|
||||
// requireUser resolves the signed-in user or writes the appropriate error and
|
||||
// returns nil. It mirrors handlePrefs: 404 when auth is disabled entirely, 401
|
||||
// for anonymous callers (the client's cue to stay on localStorage).
|
||||
func (s *Server) requireUser(w http.ResponseWriter, r *http.Request) *SessionUser {
|
||||
if s.auth == nil {
|
||||
http.Error(w, "auth disabled", http.StatusNotFound)
|
||||
return nil
|
||||
}
|
||||
u := s.auth.userFromRequest(r)
|
||||
if u == nil {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
http.Error(w, `{"error":"not signed in"}`, http.StatusUnauthorized)
|
||||
return nil
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// decodeStateBody reads a small JSON body into dst, writing a 400 on failure.
|
||||
func decodeStateBody(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
return decodeStateBodyN(w, r, dst, maxStateBodyBytes)
|
||||
}
|
||||
|
||||
// decodeStateBodyN is decodeStateBody with an explicit byte cap, for endpoints
|
||||
// (like push subscribe) whose payloads run larger than a single id + flag.
|
||||
func decodeStateBodyN(w http.ResponseWriter, r *http.Request, dst any, limit int64) bool {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, limit+1))
|
||||
if err != nil || int64(len(body)) > limit {
|
||||
http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
if err := json.Unmarshal(body, dst); err != nil {
|
||||
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// handleRead records or clears a story's read flag for the signed-in user.
|
||||
func (s *Server) handleRead(w http.ResponseWriter, r *http.Request) {
|
||||
u := s.requireUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ID int64 `json:"id"`
|
||||
Read bool `json:"read"`
|
||||
}
|
||||
if !decodeStateBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.ID <= 0 {
|
||||
http.Error(w, `{"error":"bad id"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := storage.SetRead(u.Sub, req.ID, req.Read); err != nil {
|
||||
slog.Error("state: set read failed", "sub", u.Sub, "id", req.ID, "err", err)
|
||||
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleBookmark adds or removes a bookmark for the signed-in user.
|
||||
func (s *Server) handleBookmark(w http.ResponseWriter, r *http.Request) {
|
||||
u := s.requireUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ID int64 `json:"id"`
|
||||
On bool `json:"on"`
|
||||
}
|
||||
if !decodeStateBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.ID <= 0 {
|
||||
http.Error(w, `{"error":"bad id"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := storage.SetBookmark(u.Sub, req.ID, req.On); err != nil {
|
||||
slog.Error("state: set bookmark failed", "sub", u.Sub, "id", req.ID, "err", err)
|
||||
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleState returns which of the given story ids are read and bookmarked, so
|
||||
// the client can paint a freshly rendered page in one round-trip.
|
||||
func (s *Server) handleState(w http.ResponseWriter, r *http.Request) {
|
||||
u := s.requireUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
ids := parseIDList(r.URL.Query().Get("ids"))
|
||||
read, bookmarked, err := storage.UserStoryState(u.Sub, ids)
|
||||
if err != nil {
|
||||
slog.Error("state: lookup failed", "sub", u.Sub, "err", err)
|
||||
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"read": keysOf(read),
|
||||
"bookmarked": keysOf(bookmarked),
|
||||
})
|
||||
}
|
||||
|
||||
// parseIDList turns "1,2,3" into a bounded, deduplicated slice of positive ids.
|
||||
func parseIDList(raw string) []int64 {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[int64]bool)
|
||||
out := make([]int64, 0)
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64)
|
||||
if err != nil || id <= 0 || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
out = append(out, id)
|
||||
if len(out) >= maxStateIDs {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// keysOf returns the keys of a set as a slice (order unspecified).
|
||||
func keysOf(m map[int64]bool) []int64 {
|
||||
out := make([]int64, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
26
main.go
26
main.go
@@ -21,9 +21,22 @@ import (
|
||||
"pete/internal/storage"
|
||||
"pete/internal/web"
|
||||
|
||||
webpush "github.com/SherClockHolmes/webpush-go"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// runGenVAPID prints a fresh VAPID keypair for the [web.push] config block.
|
||||
func runGenVAPID() {
|
||||
priv, pub, err := webpush.GenerateVAPIDKeys()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "genvapid: failed:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("# Add these under [web.push] in your config:")
|
||||
fmt.Printf("vapid_public_key = %q\n", pub)
|
||||
fmt.Printf("vapid_private_key = %q\n", priv)
|
||||
}
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("config", "config.toml", "path to config file")
|
||||
seed := flag.Bool("seed", false, "ingest current feed items as seen without posting, then exit")
|
||||
@@ -31,8 +44,15 @@ func main() {
|
||||
testSource := flag.String("test-source", "", "source name to use for -test (default: first enabled)")
|
||||
local := flag.Bool("local", false, "web/RSS-only mode: poll feeds and serve the web UI, no Matrix login or posting")
|
||||
backfillPaywall := flag.Bool("backfill-paywall", false, "re-check paywalled rows with current detection logic; clear false positives and fill missing thumbnails, then exit")
|
||||
genVAPID := flag.Bool("genvapid", false, "generate a VAPID keypair for web.push and print it, then exit")
|
||||
flag.Parse()
|
||||
|
||||
// -genvapid needs neither config nor database; handle it before anything else.
|
||||
if *genVAPID {
|
||||
runGenVAPID()
|
||||
return
|
||||
}
|
||||
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
})))
|
||||
@@ -240,11 +260,12 @@ func main() {
|
||||
|
||||
// Start the read-only web UI alongside the Matrix bot.
|
||||
if cfg.Web.Enabled {
|
||||
ws, err := web.New(cfg.Web, cfg.Sources)
|
||||
ws, err := web.New(cfg.Web, cfg.Sources, postingEnabled)
|
||||
if err != nil {
|
||||
slog.Error("web server init failed", "err", err)
|
||||
} else {
|
||||
go ws.Start(ctx)
|
||||
ws.StartPushSender(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,7 +370,8 @@ func runLocal(cfg *config.Config) {
|
||||
poller.Start(ctx)
|
||||
slog.Info("local: pollers started")
|
||||
|
||||
ws, err := web.New(cfg.Web, cfg.Sources)
|
||||
// Local mode never connects to Matrix, so it's always web-only.
|
||||
ws, err := web.New(cfg.Web, cfg.Sources, false)
|
||||
if err != nil {
|
||||
slog.Error("local: web server init failed", "err", err)
|
||||
os.Exit(1)
|
||||
|
||||
Reference in New Issue
Block a user