diff --git a/config.example.toml b/config.example.toml index 125d39f..bb5a826 100644 --- a/config.example.toml +++ b/config.example.toml @@ -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 tag is diff --git a/go.mod b/go.mod index 83caa26..9c2151b 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 5898a0a..a287e45 100644 --- a/go.sum +++ b/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= diff --git a/internal/config/config.go b/internal/config/config.go index 3ad5177..e39b4c7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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@ 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 diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6c6b57e..938c9c6 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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" `}, } diff --git a/internal/ingestion/poller.go b/internal/ingestion/poller.go index 8056f83..e8ac632 100644 --- a/internal/ingestion/poller.go +++ b/internal/ingestion/poller.go @@ -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 { diff --git a/internal/storage/db.go b/internal/storage/db.go index 10a9302..054a815 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -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", diff --git a/internal/storage/push.go b/internal/storage/push.go new file mode 100644 index 0000000..76fca70 --- /dev/null +++ b/internal/storage/push.go @@ -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() +} diff --git a/internal/storage/push_test.go b/internal/storage/push_test.go new file mode 100644 index 0000000..ce55af9 --- /dev/null +++ b/internal/storage/push_test.go @@ -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) + } +} diff --git a/internal/storage/queries.go b/internal/storage/queries.go index e9f58e3..00dff4d 100644 --- a/internal/storage/queries.go +++ b/internal/storage/queries.go @@ -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) { diff --git a/internal/storage/rank.go b/internal/storage/rank.go new file mode 100644 index 0000000..811e94c --- /dev/null +++ b/internal/storage/rank.go @@ -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 ") +} diff --git a/internal/storage/rank_test.go b/internal/storage/rank_test.go new file mode 100644 index 0000000..66e762e --- /dev/null +++ b/internal/storage/rank_test.go @@ -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") + } +} diff --git a/internal/storage/schema.go b/internal/storage/schema.go index 525e13e..1d15488 100644 --- a/internal/storage/schema.go +++ b/internal/storage/schema.go @@ -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 = ` diff --git a/internal/storage/sourcehealth.go b/internal/storage/sourcehealth.go new file mode 100644 index 0000000..6d02826 --- /dev/null +++ b/internal/storage/sourcehealth.go @@ -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() +} diff --git a/internal/storage/sourcehealth_test.go b/internal/storage/sourcehealth_test.go new file mode 100644 index 0000000..b4b241a --- /dev/null +++ b/internal/storage/sourcehealth_test.go @@ -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)) + } +} diff --git a/internal/storage/userstate.go b/internal/storage/userstate.go new file mode 100644 index 0000000..875a05d --- /dev/null +++ b/internal/storage/userstate.go @@ -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) + "?" +} diff --git a/internal/storage/userstate_test.go b/internal/storage/userstate_test.go new file mode 100644 index 0000000..de7232a --- /dev/null +++ b/internal/storage/userstate_test.go @@ -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) + } +} diff --git a/internal/web/feed.go b/internal/web/feed.go new file mode 100644 index 0000000..2bd03b3 --- /dev/null +++ b/internal/web/feed.go @@ -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("

") + b.WriteString(strings.ReplaceAll(escapeHTMLText(para), "\n", "
")) + b.WriteString("

") + } + 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) +} diff --git a/internal/web/feed_test.go b/internal/web/feed_test.go new file mode 100644 index 0000000..615f5e5 --- /dev/null +++ b/internal/web/feed_test.go @@ -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 & 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, "

First paragraph.

") { + 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) + } +} diff --git a/internal/web/handlers.go b/internal/web/handlers.go index e4b3bf7..51e9acc 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -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. diff --git a/internal/web/push_sender.go b/internal/web/push_sender.go new file mode 100644 index 0000000..35d6657 --- /dev/null +++ b/internal/web/push_sender.go @@ -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) +} diff --git a/internal/web/push_sender_test.go b/internal/web/push_sender_test.go new file mode 100644 index 0000000..a6083d2 --- /dev/null +++ b/internal/web/push_sender_test.go @@ -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) + } +} diff --git a/internal/web/pwa.go b/internal/web/pwa.go new file mode 100644 index 0000000..68d9006 --- /dev/null +++ b/internal/web/pwa.go @@ -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) +} diff --git a/internal/web/reader_test.go b/internal/web/reader_test.go index df5d1d1..18f30b8 100644 --- a/internal/web/reader_test.go +++ b/internal/web/reader_test.go @@ -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) } diff --git a/internal/web/server.go b/internal/web/server.go index bfc1bd1..a1db26b 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -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{ diff --git a/internal/web/static/css/input.css b/internal/web/static/css/input.css index b98270b..1efd0fd 100644 --- a/internal/web/static/css/input.css +++ b/internal/web/static/css/input.css @@ -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; } diff --git a/internal/web/static/css/output.css b/internal/web/static/css/output.css index b417a7b..bd6361c 100644 --- a/internal/web/static/css/output.css +++ b/internal/web/static/css/output.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}body{font-family:Nunito,system-ui,sans-serif}html{scroll-behavior:smooth}body,html{overflow-x:hidden}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.pete-channel-nav{scrollbar-width:none;-ms-overflow-style:none}.pete-channel-nav::-webkit-scrollbar{display:none}.pete-reader-backdrop{position:absolute;inset:0;background:rgba(20,14,6,.55);-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px)}html[data-phase=night] .pete-reader-backdrop{background:rgba(6,8,20,.65)}.pete-reader-shell{position:relative;height:100%;width:100%;max-width:44rem;margin:0 auto;display:flex;flex-direction:column;padding:max(env(safe-area-inset-top),.75rem) 1rem .75rem;gap:.75rem}.pete-reader-bar{display:flex;align-items:center;justify-content:space-between;gap:.75rem;color:#fff}.pete-reader-progress{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:600;font-size:.85rem;letter-spacing:.02em;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-nav{display:flex;align-items:center;gap:.4rem}.pete-reader-btn{display:inline-flex;align-items:center;justify-content:center;min-width:2.25rem;height:2.25rem;padding:0 .7rem;border-radius:9999px;background:hsla(0,0%,100%,.14);color:#fff;font-weight:700;font-size:.95rem;border:2px solid hsla(0,0%,100%,.18);cursor:pointer;transition:background .15s ease,transform .15s ease,opacity .15s ease}.pete-reader-btn:hover{background:hsla(0,0%,100%,.28);transform:translateY(-1px)}.pete-reader-btn:disabled{opacity:.35;cursor:default;transform:none}.pete-reader-btn-open{background:var(--accent);border-color:transparent;color:#1c1305;text-decoration:none}.pete-reader-btn-open:hover{background:var(--accent);filter:brightness(1.08)}.pete-reader-scroll{flex:1 1 auto;overflow-y:auto;-webkit-overflow-scrolling:touch;border-radius:1.75rem}.pete-reader-article{background:var(--card);color:var(--ink);border-radius:1.75rem;border:2px solid rgba(0,0,0,.06);box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.18);padding:clamp(1.25rem,4vw,2.5rem)}html[data-phase=night] .pete-reader-article{border-color:hsla(0,0%,100%,.08)}.pete-reader-eyebrow{display:flex;align-items:center;flex-wrap:wrap;gap:.5rem;font-size:.75rem;margin-bottom:.85rem}.pete-reader-chip{display:inline-flex;align-items:center;gap:.35rem;border-radius:9999px;padding:.15rem .65rem;color:#fff;font-weight:700;text-transform:uppercase;letter-spacing:.06em}.pete-reader-source{font-weight:700;opacity:.75}.pete-reader-time{opacity:.55}.pete-reader-title{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:700;line-height:1.15;letter-spacing:-.01em;font-size:clamp(1.6rem,4.5vw,2.4rem);margin-bottom:1rem}.pete-reader-hero{width:100%;border-radius:1.1rem;margin-bottom:1.25rem;background:rgba(0,0,0,.05)}.pete-reader-body{font-size:1.08rem;line-height:1.75}.pete-reader-body p{margin-bottom:1.05em}.pete-reader-body p:last-child{margin-bottom:0}.pete-reader-note{margin-top:1.25rem;padding-top:1rem;border-top:1px solid rgba(0,0,0,.1);font-size:.85rem;opacity:.7}html[data-phase=night] .pete-reader-note{border-color:hsla(0,0%,100%,.12)}.pete-reader-note a{color:var(--accent);font-weight:700}.pete-reader-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:.75rem;padding:clamp(2rem,8vw,4rem) 1rem;color:var(--ink)}.pete-reader-empty-emoji{font-size:2.5rem}.pete-reader-hint{text-align:center;color:hsla(0,0%,100%,.85);font-size:.75rem;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-hint kbd{font-family:ui-monospace,monospace;background:hsla(0,0%,100%,.16);border-radius:.3rem;padding:.05rem .3rem}@media (max-width:640px){.pete-reader-hint{display:none}}[data-story-card][data-read="1"]{opacity:.5;transition:opacity .2s ease}[data-story-card][data-read="1"]:hover{opacity:1}[data-story-card][data-read="1"]:after{content:"✓";position:absolute;top:.6rem;right:.6rem;z-index:6;display:grid;place-items:center;height:1.5rem;width:1.5rem;border-radius:9999px;background:rgba(20,14,6,.72);color:#fff;font-size:.8rem;font-weight:700}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-right-6{right:-1.5rem}.-top-6{top:-1.5rem}.-z-10{z-index:-10}.-z-\[5\]{z-index:-5}.z-50{z-index:50}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-8{margin-bottom:2rem}.mr-1{margin-right:.25rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.line-clamp-2{-webkit-line-clamp:2}.line-clamp-2,.line-clamp-3{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical}.line-clamp-3{-webkit-line-clamp:3}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.aspect-\[16\/10\]{aspect-ratio:16/10}.h-12{height:3rem}.h-2\.5{height:.625rem}.h-20{height:5rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-9{height:2.25rem}.h-full{height:100%}.max-h-\[55vh\]{max-height:55vh}.max-h-\[60vh\]{max-height:60vh}.min-h-screen{min-height:100vh}.w-12{width:3rem}.w-2\.5{width:.625rem}.w-20{width:5rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-9{width:2.25rem}.w-full{width:100%}.min-w-0{min-width:0}.max-w-2xl{max-width:42rem}.max-w-6xl{max-width:72rem}.max-w-\[7rem\]{max-width:7rem}.max-w-full{max-width:100%}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.-translate-y-0{--tw-translate-y:-0px}.-translate-y-0,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-3xl{border-radius:1.5rem}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-transparent{border-color:transparent}.bg-\[color\:var\(--accent\)\]{background-color:var(--accent)}.bg-\[color\:var\(--bg\)\]{background-color:var(--bg)}.bg-\[color\:var\(--bg-grad\)\]{background-color:var(--bg-grad)}.bg-\[color\:var\(--card\)\]{background-color:var(--card)}.bg-transparent{background-color:transparent}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-0\.5{padding:.125rem}.p-1{padding:.25rem}.p-10{padding:2.5rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-0{padding-top:0;padding-bottom:0}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.pb-1{padding-bottom:.25rem}.pb-10{padding-bottom:2.5rem}.pb-24{padding-bottom:6rem}.pb-4{padding-bottom:1rem}.pt-1{padding-top:.25rem}.pt-3{padding-top:.75rem}.pt-8{padding-top:2rem}.pt-\[10vh\]{padding-top:10vh}.pt-\[12vh\]{padding-top:12vh}.text-center{text-align:center}.align-\[-4px\]{vertical-align:-4px}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12rem\]{font-size:12rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-none{line-height:1}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-\[0\.2em\]{letter-spacing:.2em}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.text-\[color\:var\(--ink\)\]{color:var(--ink)}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-white\/85{color:hsla(0,0%,100%,.85)}.underline{text-decoration-line:underline}.decoration-4{text-decoration-thickness:4px}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.accent-\[color\:var\(--accent\)\]{accent-color:var(--accent)}.opacity-20{opacity:.2}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.blur{--tw-blur:blur(8px)}.blur,.grayscale{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.grayscale{--tw-grayscale:grayscale(100%)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px)}.backdrop-blur-sm,.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-1000{transition-duration:1s}.duration-500{transition-duration:.5s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.font-display{font-family:Fredoka,Nunito,system-ui,sans-serif;letter-spacing:-.01em}.shadow-pete{box-shadow:0 4px 0 rgba(60,40,20,.1),0 8px 24px rgba(60,40,20,.08)}.shadow-pete-lg{box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.12)}.bg-theme-gaming{background-color:#4caf7d}.bg-theme-tech{background-color:#5aa9e6}.bg-theme-politics{background-color:#e07a5f}.bg-theme-eu{background-color:#039}.bg-theme-music{background-color:#b079d6}.bg-theme-anime{background-color:#ec5e8a}.bg-theme-foss{background-color:#d97706}.bg-theme-kids{background-color:#14b8a6}.bg-theme-finance{background-color:#10b981}.bg-theme-lego{background-color:#d01012}.text-theme-gaming{color:#2d8a5a}.text-theme-tech{color:#2f7fb8}.text-theme-politics{color:#b8523a}.text-theme-eu{color:#039}.text-theme-music{color:#8a4fb8}.text-theme-anime{color:#c33a6a}.text-theme-foss{color:#b8530a}.text-theme-kids{color:#0f766e}.text-theme-finance{color:#059669}.text-theme-lego{color:#b00d0e}.decoration-theme-gaming{text-decoration-color:#4caf7d}.decoration-theme-tech{text-decoration-color:#5aa9e6}.decoration-theme-politics{text-decoration-color:#e07a5f}.decoration-theme-eu{text-decoration-color:#039}.decoration-theme-music{text-decoration-color:#b079d6}.decoration-theme-anime{text-decoration-color:#ec5e8a}.decoration-theme-foss{text-decoration-color:#d97706}.decoration-theme-kids{text-decoration-color:#14b8a6}.decoration-theme-finance{text-decoration-color:#10b981}.decoration-theme-lego{text-decoration-color:#d01012}.border-theme-gaming{border-color:#4caf7d}.border-theme-tech{border-color:#5aa9e6}.border-theme-politics{border-color:#e07a5f}.border-theme-eu{border-color:#039}.border-theme-music{border-color:#b079d6}.border-theme-anime{border-color:#ec5e8a}.border-theme-foss{border-color:#d97706}.border-theme-kids{border-color:#14b8a6}.border-theme-finance{border-color:#10b981}.border-theme-lego{border-color:#d01012}.glow-theme-gaming{box-shadow:0 0 0 2px rgba(76,175,125,.35),0 0 24px 4px rgba(76,175,125,.45)}.glow-theme-gaming,.glow-theme-tech{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-tech{box-shadow:0 0 0 2px rgba(90,169,230,.35),0 0 24px 4px rgba(90,169,230,.45)}.glow-theme-politics{box-shadow:0 0 0 2px rgba(224,122,95,.35),0 0 24px 4px rgba(224,122,95,.45)}.glow-theme-eu,.glow-theme-politics{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-eu{box-shadow:0 0 0 2px rgba(0,51,153,.35),0 0 24px 4px rgba(0,51,153,.45)}.glow-theme-music{box-shadow:0 0 0 2px rgba(176,121,214,.35),0 0 24px 4px rgba(176,121,214,.45)}.glow-theme-anime,.glow-theme-music{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-anime{box-shadow:0 0 0 2px rgba(236,94,138,.35),0 0 24px 4px rgba(236,94,138,.45)}.glow-theme-foss{box-shadow:0 0 0 2px rgba(217,119,6,.35),0 0 24px 4px rgba(217,119,6,.45)}.glow-theme-foss,.glow-theme-kids{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-kids{box-shadow:0 0 0 2px rgba(20,184,166,.35),0 0 24px 4px rgba(20,184,166,.45)}.glow-theme-finance{box-shadow:0 0 0 2px rgba(16,185,129,.35),0 0 24px 4px rgba(16,185,129,.45)}.glow-theme-finance,.glow-theme-lego{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-lego{box-shadow:0 0 0 2px rgba(208,16,18,.35),0 0 24px 4px rgba(208,16,18,.45)}@keyframes pete-glow-pulse{0%,to{filter:brightness(1)}50%{filter:brightness(1.08)}}.line-clamp-3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.paywall-stamp{position:absolute;inset:0;pointer-events:none;display:flex;align-items:center;justify-content:center;z-index:5;background:rgba(255,250,240,.18)}.paywall-stamp:before{content:"PAYWALLED";font-family:var(--font-display,ui-sans-serif),system-ui,sans-serif;font-weight:900;font-size:clamp(1rem,3.2vw,1.75rem);letter-spacing:.12em;color:rgba(180,30,30,.85);border:.28rem double rgba(180,30,30,.85);padding:.3rem .9rem;transform:rotate(-15deg);text-shadow:0 1px 0 hsla(0,0%,100%,.4);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.15);background:rgba(255,240,230,.55);border-radius:.25rem;filter:contrast(1.05)}:root,html[data-phase=day]{--bg:#fff7e4;--bg-grad:#ffeec2;--card:#fff;--ink:#3a2e1f;--accent:#f2a541}html[data-phase=dawn]{--bg:#ffe7d6;--bg-grad:#ffc9c9;--card:#fff4ea;--ink:#4a2e2a;--accent:#ff8a65}html[data-phase=dusk]{--bg:#ffd6a8;--bg-grad:#f7a07e;--card:#fff1de;--ink:#3d2417;--accent:#e6553a}html[data-phase=night]{--bg:#1a1f3a;--bg-grad:#2a3358;--card:#2d365a;--ink:#f1ecd8;--accent:#f9d976}.empty\:hidden:empty{display:none}.hover\:-translate-y-0\.5:hover{--tw-translate-y:-0.125rem}.hover\:-translate-y-0\.5:hover,.hover\:-translate-y-1:hover{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:-translate-y-1:hover{--tw-translate-y:-0.25rem}.hover\:text-\[color\:var\(--ink\)\]:hover{color:var(--ink)}.hover\:brightness-105:hover{--tw-brightness:brightness(1.05);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.hover\:shadow-pete-lg:hover{box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.12)}.focus\:border-\[color\:var\(--accent\)\]:focus{border-color:var(--accent)}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:-rotate-6{--tw-rotate:-6deg}.group:hover .group-hover\:-rotate-6,.group:hover .group-hover\:scale-105{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:scale-105{--tw-scale-x:1.05;--tw-scale-y:1.05}.group:hover .group-hover\:underline{text-decoration-line:underline}@media (min-width:640px){.sm\:block{display:block}.sm\:inline{display:inline}.sm\:flex{display:flex}.sm\:inline-flex{display:inline-flex}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:gap-2{gap:.5rem}.sm\:gap-4{gap:1rem}.sm\:p-10{padding:2.5rem}.sm\:pt-12{padding-top:3rem}.sm\:text-2xl{font-size:1.5rem;line-height:2rem}.sm\:text-5xl{font-size:3rem;line-height:1}.sm\:text-6xl{font-size:3.75rem;line-height:1}.sm\:text-lg{font-size:1.125rem;line-height:1.75rem}}@media (min-width:1024px){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}body{font-family:Nunito,system-ui,sans-serif}html{scroll-behavior:smooth}body,html{overflow-x:hidden}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.pete-channel-nav{scrollbar-width:none;-ms-overflow-style:none}.pete-channel-nav::-webkit-scrollbar{display:none}.pete-reader-backdrop{position:absolute;inset:0;background:rgba(20,14,6,.55);-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px)}html[data-phase=night] .pete-reader-backdrop{background:rgba(6,8,20,.65)}.pete-reader-shell{position:relative;height:100%;width:100%;max-width:44rem;margin:0 auto;display:flex;flex-direction:column;padding:max(env(safe-area-inset-top),.75rem) 1rem .75rem;gap:.75rem}.pete-reader-bar{display:flex;align-items:center;justify-content:space-between;gap:.75rem;color:#fff}.pete-reader-progress{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:600;font-size:.85rem;letter-spacing:.02em;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-nav{display:flex;align-items:center;gap:.4rem}.pete-reader-btn{display:inline-flex;align-items:center;justify-content:center;min-width:2.25rem;height:2.25rem;padding:0 .7rem;border-radius:9999px;background:hsla(0,0%,100%,.14);color:#fff;font-weight:700;font-size:.95rem;border:2px solid hsla(0,0%,100%,.18);cursor:pointer;transition:background .15s ease,transform .15s ease,opacity .15s ease}.pete-reader-btn:hover{background:hsla(0,0%,100%,.28);transform:translateY(-1px)}.pete-reader-btn:disabled{opacity:.35;cursor:default;transform:none}.pete-reader-btn-open{background:var(--accent);border-color:transparent;color:#1c1305;text-decoration:none}.pete-reader-btn-open:hover{background:var(--accent);filter:brightness(1.08)}.pete-reader-scroll{flex:1 1 auto;overflow-y:auto;-webkit-overflow-scrolling:touch;border-radius:1.75rem}.pete-reader-article{background:var(--card);color:var(--ink);border-radius:1.75rem;border:2px solid rgba(0,0,0,.06);box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.18);padding:clamp(1.25rem,4vw,2.5rem)}html[data-phase=night] .pete-reader-article{border-color:hsla(0,0%,100%,.08)}.pete-reader-eyebrow{display:flex;align-items:center;flex-wrap:wrap;gap:.5rem;font-size:.75rem;margin-bottom:.85rem}.pete-reader-chip{display:inline-flex;align-items:center;gap:.35rem;border-radius:9999px;padding:.15rem .65rem;color:#fff;font-weight:700;text-transform:uppercase;letter-spacing:.06em}.pete-reader-source{font-weight:700;opacity:.75}.pete-reader-time{opacity:.55}.pete-reader-title{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:700;line-height:1.15;letter-spacing:-.01em;font-size:clamp(1.6rem,4.5vw,2.4rem);margin-bottom:1rem}.pete-reader-hero{width:100%;border-radius:1.1rem;margin-bottom:1.25rem;background:rgba(0,0,0,.05)}.pete-reader-body{font-size:1.08rem;line-height:1.75}.pete-reader-body p{margin-bottom:1.05em}.pete-reader-body p:last-child{margin-bottom:0}.pete-reader-note{margin-top:1.25rem;padding-top:1rem;border-top:1px solid rgba(0,0,0,.1);font-size:.85rem;opacity:.7}html[data-phase=night] .pete-reader-note{border-color:hsla(0,0%,100%,.12)}.pete-reader-note a{color:var(--accent);font-weight:700}.pete-reader-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:.75rem;padding:clamp(2rem,8vw,4rem) 1rem;color:var(--ink)}.pete-reader-empty-emoji{font-size:2.5rem}.pete-reader-hint{text-align:center;color:hsla(0,0%,100%,.85);font-size:.75rem;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-hint kbd{font-family:ui-monospace,monospace;background:hsla(0,0%,100%,.16);border-radius:.3rem;padding:.05rem .3rem}@media (max-width:640px){.pete-reader-hint{display:none}}.pete-reader-related{width:100%;margin:.85rem auto 0}.pete-reader-related-title{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:700;font-size:.95rem;color:#fff;margin:0 0 .6rem;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-related-grid{display:grid;gap:.6rem}.pete-reader-related-card{display:flex;align-items:center;gap:.75rem;background:var(--card);color:var(--ink);border-radius:1rem;border:2px solid rgba(0,0,0,.06);padding:.6rem;text-decoration:none;transition:transform .15s ease,box-shadow .15s ease}.pete-reader-related-card:hover{transform:translateY(-1px);box-shadow:0 6px 16px rgba(60,40,20,.16)}html[data-phase=night] .pete-reader-related-card{border-color:hsla(0,0%,100%,.08)}.pete-reader-related-thumb{width:4.5rem;height:3.25rem;flex-shrink:0;-o-object-fit:cover;object-fit:cover;border-radius:.6rem;background:rgba(0,0,0,.06)}.pete-reader-related-meta{min-width:0;display:flex;flex-direction:column;gap:.25rem}.pete-reader-related-eyebrow{display:flex;align-items:center;flex-wrap:wrap;gap:.4rem;font-size:.7rem}.pete-reader-related-source{font-weight:700;opacity:.7}.pete-reader-related-headline{font-weight:700;line-height:1.25;font-size:.92rem;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}[data-story-card][data-read="1"]{opacity:.5;transition:opacity .2s ease}[data-story-card][data-read="1"]:hover{opacity:1}[data-story-card][data-read="1"]:after{content:"✓";position:absolute;top:.6rem;right:.6rem;z-index:6;display:grid;place-items:center;height:1.5rem;width:1.5rem;border-radius:9999px;background:rgba(20,14,6,.72);color:#fff;font-size:.8rem;font-weight:700}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-right-6{right:-1.5rem}.-top-6{top:-1.5rem}.-z-10{z-index:-10}.-z-\[5\]{z-index:-5}.z-50{z-index:50}.mx-auto{margin-left:auto;margin-right:auto}.-mt-1{margin-top:-.25rem}.mb-1{margin-bottom:.25rem}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-8{margin-bottom:2rem}.mr-1{margin-right:.25rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.line-clamp-2{-webkit-line-clamp:2}.line-clamp-2,.line-clamp-3{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical}.line-clamp-3{-webkit-line-clamp:3}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.aspect-\[16\/10\]{aspect-ratio:16/10}.h-12{height:3rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-20{height:5rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-9{height:2.25rem}.h-full{height:100%}.max-h-\[55vh\]{max-height:55vh}.max-h-\[60vh\]{max-height:60vh}.min-h-screen{min-height:100vh}.w-12{width:3rem}.w-2{width:.5rem}.w-2\.5{width:.625rem}.w-20{width:5rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-9{width:2.25rem}.w-full{width:100%}.min-w-0{min-width:0}.min-w-\[52rem\]{min-width:52rem}.max-w-2xl{max-width:42rem}.max-w-6xl{max-width:72rem}.max-w-\[7rem\]{max-width:7rem}.max-w-full{max-width:100%}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.flex-shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.-translate-y-0{--tw-translate-y:-0px}.-translate-y-0,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-3xl{border-radius:1.5rem}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-transparent{border-color:transparent}.bg-\[color\:var\(--accent\)\]{background-color:var(--accent)}.bg-\[color\:var\(--bg\)\]{background-color:var(--bg)}.bg-\[color\:var\(--bg-grad\)\]{background-color:var(--bg-grad)}.bg-\[color\:var\(--card\)\]{background-color:var(--card)}.bg-emerald-500\/15{background-color:rgba(16,185,129,.15)}.bg-red-500\/15{background-color:rgba(239,68,68,.15)}.bg-transparent{background-color:transparent}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-0\.5{padding:.125rem}.p-1{padding:.25rem}.p-10{padding:2.5rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-0{padding-top:0;padding-bottom:0}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pb-10{padding-bottom:2.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pt-1{padding-top:.25rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-8{padding-top:2rem}.pt-\[10vh\]{padding-top:10vh}.pt-\[12vh\]{padding-top:12vh}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-top{vertical-align:top}.align-\[-4px\]{vertical-align:-4px}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12rem\]{font-size:12rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-none{line-height:1}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-\[0\.2em\]{letter-spacing:.2em}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.text-\[\#1c1305\]{--tw-text-opacity:1;color:rgb(28 19 5/var(--tw-text-opacity,1))}.text-\[color\:var\(--ink\)\]{color:var(--ink)}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-emerald-700{--tw-text-opacity:1;color:rgb(4 120 87/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-white\/85{color:hsla(0,0%,100%,.85)}.underline{text-decoration-line:underline}.decoration-4{text-decoration-thickness:4px}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.accent-\[color\:var\(--accent\)\]{accent-color:var(--accent)}.opacity-20{opacity:.2}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.blur{--tw-blur:blur(8px)}.blur,.grayscale{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.grayscale{--tw-grayscale:grayscale(100%)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px)}.backdrop-blur-sm,.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-1000{transition-duration:1s}.duration-500{transition-duration:.5s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.font-display{font-family:Fredoka,Nunito,system-ui,sans-serif;letter-spacing:-.01em}.shadow-pete{box-shadow:0 4px 0 rgba(60,40,20,.1),0 8px 24px rgba(60,40,20,.08)}.shadow-pete-lg{box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.12)}.bg-theme-gaming{background-color:#4caf7d}.bg-theme-tech{background-color:#5aa9e6}.bg-theme-politics{background-color:#e07a5f}.bg-theme-eu{background-color:#039}.bg-theme-music{background-color:#b079d6}.bg-theme-anime{background-color:#ec5e8a}.bg-theme-foss{background-color:#d97706}.bg-theme-kids{background-color:#14b8a6}.bg-theme-finance{background-color:#10b981}.bg-theme-lego{background-color:#d01012}.text-theme-gaming{color:#2d8a5a}.text-theme-tech{color:#2f7fb8}.text-theme-politics{color:#b8523a}.text-theme-eu{color:#039}.text-theme-music{color:#8a4fb8}.text-theme-anime{color:#c33a6a}.text-theme-foss{color:#b8530a}.text-theme-kids{color:#0f766e}.text-theme-finance{color:#059669}.text-theme-lego{color:#b00d0e}.decoration-theme-gaming{text-decoration-color:#4caf7d}.decoration-theme-tech{text-decoration-color:#5aa9e6}.decoration-theme-politics{text-decoration-color:#e07a5f}.decoration-theme-eu{text-decoration-color:#039}.decoration-theme-music{text-decoration-color:#b079d6}.decoration-theme-anime{text-decoration-color:#ec5e8a}.decoration-theme-foss{text-decoration-color:#d97706}.decoration-theme-kids{text-decoration-color:#14b8a6}.decoration-theme-finance{text-decoration-color:#10b981}.decoration-theme-lego{text-decoration-color:#d01012}.border-theme-gaming{border-color:#4caf7d}.border-theme-tech{border-color:#5aa9e6}.border-theme-politics{border-color:#e07a5f}.border-theme-eu{border-color:#039}.border-theme-music{border-color:#b079d6}.border-theme-anime{border-color:#ec5e8a}.border-theme-foss{border-color:#d97706}.border-theme-kids{border-color:#14b8a6}.border-theme-finance{border-color:#10b981}.border-theme-lego{border-color:#d01012}.glow-theme-gaming{box-shadow:0 0 0 2px rgba(76,175,125,.35),0 0 24px 4px rgba(76,175,125,.45)}.glow-theme-gaming,.glow-theme-tech{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-tech{box-shadow:0 0 0 2px rgba(90,169,230,.35),0 0 24px 4px rgba(90,169,230,.45)}.glow-theme-politics{box-shadow:0 0 0 2px rgba(224,122,95,.35),0 0 24px 4px rgba(224,122,95,.45)}.glow-theme-eu,.glow-theme-politics{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-eu{box-shadow:0 0 0 2px rgba(0,51,153,.35),0 0 24px 4px rgba(0,51,153,.45)}.glow-theme-music{box-shadow:0 0 0 2px rgba(176,121,214,.35),0 0 24px 4px rgba(176,121,214,.45)}.glow-theme-anime,.glow-theme-music{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-anime{box-shadow:0 0 0 2px rgba(236,94,138,.35),0 0 24px 4px rgba(236,94,138,.45)}.glow-theme-foss{box-shadow:0 0 0 2px rgba(217,119,6,.35),0 0 24px 4px rgba(217,119,6,.45)}.glow-theme-foss,.glow-theme-kids{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-kids{box-shadow:0 0 0 2px rgba(20,184,166,.35),0 0 24px 4px rgba(20,184,166,.45)}.glow-theme-finance{box-shadow:0 0 0 2px rgba(16,185,129,.35),0 0 24px 4px rgba(16,185,129,.45)}.glow-theme-finance,.glow-theme-lego{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-lego{box-shadow:0 0 0 2px rgba(208,16,18,.35),0 0 24px 4px rgba(208,16,18,.45)}@keyframes pete-glow-pulse{0%,to{filter:brightness(1)}50%{filter:brightness(1.08)}}.line-clamp-3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.paywall-stamp{position:absolute;inset:0;pointer-events:none;display:flex;align-items:center;justify-content:center;z-index:5;background:rgba(255,250,240,.18)}.paywall-stamp:before{content:"PAYWALLED";font-family:var(--font-display,ui-sans-serif),system-ui,sans-serif;font-weight:900;font-size:clamp(1rem,3.2vw,1.75rem);letter-spacing:.12em;color:rgba(180,30,30,.85);border:.28rem double rgba(180,30,30,.85);padding:.3rem .9rem;transform:rotate(-15deg);text-shadow:0 1px 0 hsla(0,0%,100%,.4);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.15);background:rgba(255,240,230,.55);border-radius:.25rem;filter:contrast(1.05)}:root,html[data-phase=day]{--bg:#fff7e4;--bg-grad:#ffeec2;--card:#fff;--ink:#3a2e1f;--accent:#f2a541}html[data-phase=dawn]{--bg:#ffe7d6;--bg-grad:#ffc9c9;--card:#fff4ea;--ink:#4a2e2a;--accent:#ff8a65}html[data-phase=dusk]{--bg:#ffd6a8;--bg-grad:#f7a07e;--card:#fff1de;--ink:#3d2417;--accent:#e6553a}html[data-phase=night]{--bg:#1a1f3a;--bg-grad:#2a3358;--card:#2d365a;--ink:#f1ecd8;--accent:#f9d976}.last\:border-0:last-child{border-width:0}.empty\:hidden:empty{display:none}.hover\:-translate-y-0\.5:hover{--tw-translate-y:-0.125rem}.hover\:-translate-y-0\.5:hover,.hover\:-translate-y-1:hover{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:-translate-y-1:hover{--tw-translate-y:-0.25rem}.hover\:text-\[color\:var\(--ink\)\]:hover{color:var(--ink)}.hover\:brightness-105:hover{--tw-brightness:brightness(1.05);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.hover\:shadow-pete-lg:hover{box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.12)}.focus\:border-\[color\:var\(--accent\)\]:focus{border-color:var(--accent)}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:-rotate-6{--tw-rotate:-6deg}.group:hover .group-hover\:-rotate-6,.group:hover .group-hover\:scale-105{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:scale-105{--tw-scale-x:1.05;--tw-scale-y:1.05}.group:hover .group-hover\:underline{text-decoration-line:underline}@media (min-width:640px){.sm\:block{display:block}.sm\:inline{display:inline}.sm\:flex{display:flex}.sm\:inline-flex{display:inline-flex}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:gap-2{gap:.5rem}.sm\:gap-4{gap:1rem}.sm\:p-10{padding:2.5rem}.sm\:p-8{padding:2rem}.sm\:pt-12{padding-top:3rem}.sm\:text-2xl{font-size:1.5rem;line-height:2rem}.sm\:text-4xl{font-size:2.25rem;line-height:2.5rem}.sm\:text-5xl{font-size:3rem;line-height:1}.sm\:text-6xl{font-size:3.75rem;line-height:1}.sm\:text-lg{font-size:1.125rem;line-height:1.75rem}}@media (min-width:1024px){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (prefers-color-scheme:dark){.dark\:text-amber-400{--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.dark\:text-emerald-300{--tw-text-opacity:1;color:rgb(110 231 183/var(--tw-text-opacity,1))}.dark\:text-red-300{--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.dark\:text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}} \ No newline at end of file diff --git a/internal/web/static/img/apple-touch-icon.png b/internal/web/static/img/apple-touch-icon.png new file mode 100644 index 0000000..1872373 Binary files /dev/null and b/internal/web/static/img/apple-touch-icon.png differ diff --git a/internal/web/static/img/icon-192.png b/internal/web/static/img/icon-192.png new file mode 100644 index 0000000..f9e548b Binary files /dev/null and b/internal/web/static/img/icon-192.png differ diff --git a/internal/web/static/img/icon-512.png b/internal/web/static/img/icon-512.png new file mode 100644 index 0000000..a28a398 Binary files /dev/null and b/internal/web/static/img/icon-512.png differ diff --git a/internal/web/static/img/maskable-512.png b/internal/web/static/img/maskable-512.png new file mode 100644 index 0000000..aeb19f5 Binary files /dev/null and b/internal/web/static/img/maskable-512.png differ diff --git a/internal/web/static/js/pwa.js b/internal/web/static/js/pwa.js new file mode 100644 index 0000000..c3f18d1 --- /dev/null +++ b/internal/web/static/js/pwa.js @@ -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(); + } +})(); diff --git a/internal/web/static/js/reader.js b/internal/web/static/js/reader.js index 3bec300..645192f 100644 --- a/internal/web/static/js/reader.js +++ b/internal/web/static/js/reader.js @@ -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 = '' + + '"; + 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 ; 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; + } } }); })(); diff --git a/internal/web/static/manifest.webmanifest b/internal/web/static/manifest.webmanifest new file mode 100644 index 0000000..874c25f --- /dev/null +++ b/internal/web/static/manifest.webmanifest @@ -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" } + ] +} diff --git a/internal/web/static/sw.js b/internal/web/static/sw.js new file mode 100644 index 0000000..47ba3a5 --- /dev/null +++ b/internal/web/static/sw.js @@ -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( + "" + + "Offline · Pete" + + "
" + + "
🦆
" + + "

You're offline

" + + "

Pete can't reach the news right now. Articles you've already opened are still readable — head back and try one of those.

" + + "
", + { 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); + }) + ); +}); diff --git a/internal/web/status.go b/internal/web/status.go new file mode 100644 index 0000000..6d9f0ec --- /dev/null +++ b/internal/web/status.go @@ -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}) +} diff --git a/internal/web/status_render_test.go b/internal/web/status_render_test.go new file mode 100644 index 0000000..792d8c9 --- /dev/null +++ b/internal/web/status_render_test.go @@ -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) + } + } +} diff --git a/internal/web/templates/_card.html b/internal/web/templates/_card.html index 50c29a6..3db1171 100644 --- a/internal/web/templates/_card.html +++ b/internal/web/templates/_card.html @@ -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"> + + + {{if .Story.ImageURL}}
+
+ +
+

saved for later

+

Bookmarks

+

Stories you've tucked away. Tap the bookmark on any card to add or remove one.

+

{{.Total}} saved

+
+
+ + +{{if .Stories}} +
+ {{range .Stories}} + {{template "card" dict "Story" . "Theme" .Channel "ShowChannel" true}} + {{end}} +
+ +
+{{else}} +
+ Nothing saved yet. Browse the feed and tap the 🔖 on a story to keep it here. +
+{{end}} +{{end}} diff --git a/internal/web/templates/for-you.html b/internal/web/templates/for-you.html new file mode 100644 index 0000000..3f1359d --- /dev/null +++ b/internal/web/templates/for-you.html @@ -0,0 +1,26 @@ +{{define "title"}}For you — {{.SiteTitle}}{{end}} + +{{define "main"}} +
+
+ +
+

picked for you

+

For you

+

Fresh stories weighted toward the channels and sources you read and bookmark most. The more you read, the sharper this gets.

+
+
+
+ +{{if .Stories}} +
+ {{range .Stories}} + {{template "card" dict "Story" . "Theme" .Channel "ShowChannel" true}} + {{end}} +
+{{else}} +
+ Nothing to tune yet. Read a few stories or bookmark the ones you like, and Pete will start building a feed around them. +
+{{end}} +{{end}} diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index 560b4a1..10ae816 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -6,14 +6,34 @@ a friendlier news feed.

+ {{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}}

-{{if .JustPosted}} +{{if .ForYou}} +
+
+

+ For you +

+ more → +
+
+ {{range .ForYou}} + {{template "card" dict "Story" . "Theme" .Channel "ShowChannel" true}} + {{end}} +
+
+{{end}} + +{{if and .PostingEnabled .JustPosted}}

@@ -34,7 +54,7 @@

Channels

- last 24 hours + {{if .PostingEnabled}}last 24 hours{{else}}what Pete's tracking{{end}}
{{range .Stats}} @@ -47,6 +67,7 @@ {{.Channel.Title}}
+ {{if $.PostingEnabled}}
last post
@@ -57,6 +78,7 @@
posted 24h
{{.PostsToday}}
+ {{end}}
stories
{{.Total}}
diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index 71cc389..3ead847 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -9,6 +9,17 @@ + + + + + + + + + {{if .Active}}{{if ne .Active "bookmarks"}} + + {{end}}{{end}} @@ -214,5 +270,6 @@ + {{end}} diff --git a/internal/web/templates/status.html b/internal/web/templates/status.html new file mode 100644 index 0000000..161286b --- /dev/null +++ b/internal/web/templates/status.html @@ -0,0 +1,80 @@ +{{define "title"}}Source health — {{.SiteTitle}}{{end}} + +{{define "main"}} +
+
+

owner view

+

Source health

+

+ Per-feed poll status and content stats. Rows that are currently failing float to the top. +

+
+ + {{len .Sources}} sources + + {{if .DegradedCnt}} + + ⚠ {{.DegradedCnt}} degraded + + {{else}} + + ✓ all healthy + + {{end}} +
+
+
+ +
+ + + + + + + + + + + + + + + + + {{range .Sources}} + + + + + + + + + + + + + {{if .LastError}}{{if not .Healthy}} + + + + {{end}}{{end}} + {{else}} + + {{end}} + +
SourceChannelStatusLast pollLast successItemsStoriesPaywallLast storyLast posted
{{.Name}}{{.Channel}} + {{if .NeverRun}} + ◌ idle + {{else if .Healthy}} + ● ok + {{else}} + ✕ {{.Failures}} fail{{if ne .Failures 1}}s{{end}} + {{end}} + {{if .LastPollAt.IsZero}}never{{else}}{{timeAgo .LastPollAt}}{{end}}{{if .LastSuccessAt.IsZero}}never{{else}}{{timeAgo .LastSuccessAt}}{{end}}{{.LastItemCount}}{{.Total}} + {{if .Total}}{{.PaywallRate}}%{{else}}—{{end}} + {{if .LastSeenAt.IsZero}}—{{else}}{{timeAgo .LastSeenAt}}{{end}}{{if .LastPostedAt.IsZero}}—{{else}}{{timeAgo .LastPostedAt}}{{end}}
{{.LastError}}
No sources configured.
+
+{{end}} diff --git a/internal/web/userstate_api.go b/internal/web/userstate_api.go new file mode 100644 index 0000000..b2c0125 --- /dev/null +++ b/internal/web/userstate_api.go @@ -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 +} diff --git a/main.go b/main.go index 6b6b56c..09f07a4 100644 --- a/main.go +++ b/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)