A multi-session build turning Pete's read-only web UI into something people return to. Five phases, signed-in features keyed off the OIDC subject; anonymous visitors keep the reverse-chron feed and localStorage-only state. Phase 1 — per-user read + bookmark state: user_story_state table + storage/userstate.go; auth-gated /api/read, /api/bookmark, /api/state and a /bookmarks page; reader.js syncs state server-side for signed-in users. Also hides the Matrix-posting UI when posting.enabled=false (web-only mode). Phase 2 — outbound feeds: storage.ListForFeed + web/feed.go hand-build RSS 2.0 (content:encoded) and JSON Feed 1.1 (no new dep); /feed.xml, /feed.json and per-channel variants; <link rel=alternate> discovery tags. Phase 3 — "For you" + related: storage/rank.go scores recent unread candidates by channel/source affinity + recency decay; RelatedStories via FTS5. ForYou rail + /for-you page; public /api/related feeds the reader's "You might also like". Phase 4 — source-health dashboard: source_health table + storage/sourcehealth.go (RecordPollResult, ListSourceHealth, SourceContentStats), written by the poller; admin-gated /status page behind web.admin_subs. Phase 5 — PWA + offline reader + Web Push: root-scoped manifest.webmanifest and sw.js (app-shell precache, /api/article runtime cache for offline reading, offline fallback, push/notificationclick handlers); PNG icons from pete.avif; pwa.js registers the SW and drives a notifications toggle. Web Push adds webpush-go, a [web.push] config block (pete -genvapid mints VAPID keys), a push_subscriptions table, auth-gated subscribe/unsubscribe endpoints, and a digest sender that pings each subscriber "N new stories" past their watermark, honoring disabled-sources and pruning gone endpoints. Tests added beside each new storage/web file; go test ./... and go vet clean.
167 lines
7.1 KiB
Go
167 lines
7.1 KiB
Go
package storage
|
|
|
|
const schema = `
|
|
CREATE TABLE IF NOT EXISTS stories (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
guid TEXT UNIQUE NOT NULL,
|
|
headline TEXT NOT NULL,
|
|
lede TEXT,
|
|
content TEXT,
|
|
image_url TEXT,
|
|
article_url TEXT NOT NULL,
|
|
url_canonical TEXT,
|
|
headline_norm TEXT,
|
|
source TEXT NOT NULL,
|
|
platforms TEXT,
|
|
channel TEXT,
|
|
classified INTEGER NOT NULL DEFAULT 0,
|
|
paywalled INTEGER NOT NULL DEFAULT 0,
|
|
seen_at INTEGER NOT NULL,
|
|
published_at INTEGER
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS post_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
guid TEXT NOT NULL,
|
|
channel TEXT NOT NULL,
|
|
event_id TEXT,
|
|
url_canonical TEXT,
|
|
posted_at INTEGER NOT NULL,
|
|
forced INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS round_robin_state (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
last_channel TEXT,
|
|
last_tick_at INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS reactions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
post_guid TEXT NOT NULL,
|
|
channel TEXT NOT NULL,
|
|
event_id TEXT NOT NULL,
|
|
emoji TEXT NOT NULL,
|
|
user_id TEXT NOT NULL,
|
|
reacted_at INTEGER NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS user_preferences (
|
|
user_sub TEXT PRIMARY KEY,
|
|
prefs TEXT NOT NULL,
|
|
username TEXT,
|
|
email TEXT,
|
|
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.
|
|
CREATE TABLE IF NOT EXISTS page_views (
|
|
path TEXT NOT NULL,
|
|
day INTEGER NOT NULL, -- unix day (floor(unix / 86400)), UTC
|
|
views INTEGER NOT NULL DEFAULT 0,
|
|
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
|
|
-- to dedup within a single day, then prune.
|
|
CREATE TABLE IF NOT EXISTS daily_visitors (
|
|
day INTEGER NOT NULL,
|
|
visitor TEXT NOT NULL,
|
|
PRIMARY KEY (day, visitor)
|
|
);
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_post_log_guid_channel ON post_log(guid, channel);
|
|
CREATE INDEX IF NOT EXISTS idx_post_log_event_id ON post_log(event_id);
|
|
CREATE INDEX IF NOT EXISTS idx_post_log_channel_posted ON post_log(channel, posted_at);
|
|
CREATE INDEX IF NOT EXISTS idx_post_log_canonical_channel ON post_log(url_canonical, channel, posted_at);
|
|
CREATE INDEX IF NOT EXISTS idx_stories_classified_source ON stories(classified, source);
|
|
CREATE INDEX IF NOT EXISTS idx_stories_channel_classified_seen ON stories(channel, classified, seen_at DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_stories_classified_seen ON stories(classified, seen_at DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_stories_image_url ON stories(image_url) WHERE image_url IS NOT NULL AND image_url <> '';
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_stories_url_canonical ON stories(url_canonical) WHERE url_canonical IS NOT NULL AND url_canonical <> '';
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_stories_source_headline_norm ON stories(source, headline_norm) WHERE headline_norm IS NOT NULL AND headline_norm <> '';
|
|
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 = `
|
|
CREATE VIRTUAL TABLE stories_fts USING fts5(
|
|
guid UNINDEXED,
|
|
headline,
|
|
lede,
|
|
source UNINDEXED,
|
|
platforms UNINDEXED,
|
|
content='stories',
|
|
content_rowid='id'
|
|
);
|
|
`
|
|
|
|
const ftsTriggers = `
|
|
CREATE TRIGGER stories_fts_insert AFTER INSERT ON stories BEGIN
|
|
INSERT INTO stories_fts(rowid, guid, headline, lede, source, platforms)
|
|
VALUES (new.id, new.guid, new.headline, new.lede, new.source, new.platforms);
|
|
END;
|
|
|
|
CREATE TRIGGER stories_fts_delete AFTER DELETE ON stories BEGIN
|
|
INSERT INTO stories_fts(stories_fts, rowid, guid, headline, lede, source, platforms)
|
|
VALUES ('delete', old.id, old.guid, old.headline, old.lede, old.source, old.platforms);
|
|
END;
|
|
|
|
CREATE TRIGGER stories_fts_update AFTER UPDATE ON stories BEGIN
|
|
INSERT INTO stories_fts(stories_fts, rowid, guid, headline, lede, source, platforms)
|
|
VALUES ('delete', old.id, old.guid, old.headline, old.lede, old.source, old.platforms);
|
|
INSERT INTO stories_fts(rowid, guid, headline, lede, source, platforms)
|
|
VALUES (new.id, new.guid, new.headline, new.lede, new.source, new.platforms);
|
|
END;
|
|
`
|