From 1a778e133449db4efd19cb9076b734f502c2acfb Mon Sep 17 00:00:00 2001 From: jay Date: Wed, 3 Jun 2026 18:21:49 +0000 Subject: [PATCH] Admin step A: privacy-respecting first-party event logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - events table (kind, article_id, visitor_hash, day) with a UNIQUE key that dedups to one row per visitor-day — caps volume and makes counts mean distinct visitor-days. NO ip/ua/referrer/url. Groupings derived from article_id at query time, never stored. - POST /api/events (public): whitelisted kinds (visit/open/share_ub/copy_source/ native_share/source_click); visitor token hashed server-side (never raw). - Frontend analytics.js: random localStorage visitor token; track() via sendBeacon; visit once/day; open on article click; share_ub/copy_source/native_share from the share menu; /a landing pages fire source_click. 127 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/lib/analytics.js | 49 +++++++++++++++++ .../src/lib/components/ArticleCard.svelte | 15 ++++-- frontend/src/routes/+page.svelte | 2 + goodnews/api.py | 31 +++++++++++ goodnews/db.py | 18 +++++++ goodnews/share.py | 16 ++++++ tests/test_events.py | 53 +++++++++++++++++++ 7 files changed, 180 insertions(+), 4 deletions(-) create mode 100644 frontend/src/lib/analytics.js create mode 100644 tests/test_events.py diff --git a/frontend/src/lib/analytics.js b/frontend/src/lib/analytics.js new file mode 100644 index 0000000..bff4f44 --- /dev/null +++ b/frontend/src/lib/analytics.js @@ -0,0 +1,49 @@ +// Privacy-respecting, first-party analytics. The visitor token is a random +// localStorage string — never email, IP, or a fingerprint. It only lets the +// admin dashboard tell "new vs returning" in aggregate. Server hashes it. +const VISITOR_KEY = 'goodnews:visitor'; +const VISITDAY_KEY = 'goodnews:visitday'; + +function visitorId() { + try { + let v = localStorage.getItem(VISITOR_KEY); + if (!v) { + v = crypto?.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2) + Date.now(); + localStorage.setItem(VISITOR_KEY, v); + } + return v; + } catch { + return ''; + } +} + +export function track(kind, articleId = 0) { + try { + const body = JSON.stringify({ kind, article_id: articleId || 0, visitor: visitorId() }); + if (navigator.sendBeacon) { + navigator.sendBeacon('/api/events', new Blob([body], { type: 'application/json' })); + } else { + fetch('/api/events', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + keepalive: true, + }).catch(() => {}); + } + } catch { + /* best-effort, never throws */ + } +} + +// Count a visit at most once per day per device. +export function trackVisit() { + try { + const today = new Date().toISOString().slice(0, 10); + if (localStorage.getItem(VISITDAY_KEY) !== today) { + localStorage.setItem(VISITDAY_KEY, today); + track('visit'); + } + } catch { + /* ignore */ + } +} diff --git a/frontend/src/lib/components/ArticleCard.svelte b/frontend/src/lib/components/ArticleCard.svelte index 34f87bf..10f02e0 100644 --- a/frontend/src/lib/components/ArticleCard.svelte +++ b/frontend/src/lib/components/ArticleCard.svelte @@ -1,7 +1,13 @@ """ + + # Fire a privacy-respecting 'source_click' when the reader heads to the source + # (reuses the SPA's anonymous visitor token from same-origin localStorage). + src_click = f"""""" return f""" @@ -134,6 +149,7 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None) + {src_click} {poll} """ diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 0000000..4ba5c40 --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,53 @@ +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture +def app_db(tmp_path, monkeypatch): + db = tmp_path / "t.sqlite3" + monkeypatch.setenv("GOODNEWS_DB", str(db)) + monkeypatch.setenv("GOODNEWS_SESSION_SECRET", "test-secret") + import importlib + import goodnews.api as api + importlib.reload(api) + from goodnews.db import connect, init_db + connect(str(db)).close() # creates schema lazily? ensure init + c = connect(str(db)); init_db(c); c.close() + return api.create_app(), db + + +def _count(db, **where): + from goodnews.db import connect + c = connect(str(db)) + clause = " AND ".join(f"{k}=?" for k in where) + sql = "SELECT COUNT(*) FROM events" + (f" WHERE {clause}" if where else "") + n = c.execute(sql, tuple(where.values())).fetchone()[0] + c.close() + return n + + +def test_event_recorded_and_deduped(app_db): + app, db = app_db + tc = TestClient(app) + for _ in range(3): # same (kind, article, visitor, day) → one row + assert tc.post("/api/events", json={"kind": "open", "article_id": 5, "visitor": "tok"}).json() == {"ok": True} + assert _count(db, kind="open", article_id=5) == 1 + # a different visitor is a distinct row + tc.post("/api/events", json={"kind": "open", "article_id": 5, "visitor": "other"}) + assert _count(db, kind="open", article_id=5) == 2 + + +def test_visitor_token_is_hashed_not_stored_raw(app_db): + app, db = app_db + TestClient(app).post("/api/events", json={"kind": "visit", "visitor": "secret-token"}) + from goodnews.db import connect + c = connect(str(db)) + vh = c.execute("SELECT visitor_hash FROM events").fetchone()[0] + c.close() + assert vh and vh != "secret-token" and len(vh) == 64 # sha256 hex + + +def test_unknown_kind_is_ignored(app_db): + app, db = app_db + assert TestClient(app).post("/api/events", json={"kind": "evil", "visitor": "x"}).json() == {"ok": True} + assert _count(db) == 0