Admin step A: privacy-respecting first-party event logging
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
<script>
|
<script>
|
||||||
import { auth, savedIds, toggleSave } from '$lib/auth.svelte.js';
|
import { auth, savedIds, toggleSave } from '$lib/auth.svelte.js';
|
||||||
|
import { track } from '$lib/analytics.js';
|
||||||
|
|
||||||
let { article, onaction, onreplace, ontag, onimageerror, onview, hero = false } = $props();
|
let { article, onaction, onreplace, ontag, onimageerror, onview, hero = false } = $props();
|
||||||
|
|
||||||
|
function opened() {
|
||||||
|
onview?.(article);
|
||||||
|
track('open', article.id);
|
||||||
|
}
|
||||||
let isSaved = $derived(savedIds.has(article.id));
|
let isSaved = $derived(savedIds.has(article.id));
|
||||||
|
|
||||||
const humanize = (s) => (s || '').replace(/-/g, ' ');
|
const humanize = (s) => (s || '').replace(/-/g, ' ');
|
||||||
@@ -52,6 +58,7 @@
|
|||||||
async function nativeShare() {
|
async function nativeShare() {
|
||||||
try {
|
try {
|
||||||
await navigator.share({ title: article.title, url: shareUrl });
|
await navigator.share({ title: article.title, url: shareUrl });
|
||||||
|
track('native_share', article.id);
|
||||||
} catch {
|
} catch {
|
||||||
/* user cancelled */
|
/* user cancelled */
|
||||||
}
|
}
|
||||||
@@ -80,7 +87,7 @@
|
|||||||
data-topic={article.topic ?? ''}
|
data-topic={article.topic ?? ''}
|
||||||
>
|
>
|
||||||
{#if showImage}
|
{#if showImage}
|
||||||
<a class="media" href={safeHref} target="_blank" rel="noopener" onclick={() => onview?.(article)}>
|
<a class="media" href={safeHref} target="_blank" rel="noopener" onclick={opened}>
|
||||||
<img src={article.image_url} alt="" loading="lazy" referrerpolicy="no-referrer"
|
<img src={article.image_url} alt="" loading="lazy" referrerpolicy="no-referrer"
|
||||||
onerror={() => { failed = true; onimageerror?.(); }} />
|
onerror={() => { failed = true; onimageerror?.(); }} />
|
||||||
</a>
|
</a>
|
||||||
@@ -101,7 +108,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="src">{article.source}</div>
|
<div class="src">{article.source}</div>
|
||||||
|
|
||||||
<h3><a href={safeHref} target="_blank" rel="noopener" onclick={() => onview?.(article)}>{article.title}</a></h3>
|
<h3><a href={safeHref} target="_blank" rel="noopener" onclick={opened}>{article.title}</a></h3>
|
||||||
|
|
||||||
{#if hero && article.description}
|
{#if hero && article.description}
|
||||||
<p class="desc">{article.description}</p>
|
<p class="desc">{article.description}</p>
|
||||||
@@ -139,10 +146,10 @@
|
|||||||
{#if canNativeShare}
|
{#if canNativeShare}
|
||||||
<button role="menuitem" onclick={nativeShare}>Share…</button>
|
<button role="menuitem" onclick={nativeShare}>Share…</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button role="menuitem" onclick={() => copy(shareUrl, 'link')}>
|
<button role="menuitem" onclick={() => { copy(shareUrl, 'link'); track('share_ub', article.id); }}>
|
||||||
{copied === 'link' ? 'Copied!' : 'Copy link'}
|
{copied === 'link' ? 'Copied!' : 'Copy link'}
|
||||||
</button>
|
</button>
|
||||||
<button role="menuitem" onclick={() => copy(article.url, 'source')}>
|
<button role="menuitem" onclick={() => { copy(article.url, 'source'); track('copy_source', article.id); }}>
|
||||||
{copied === 'source' ? 'Copied!' : 'Copy source link'}
|
{copied === 'source' ? 'Copied!' : 'Copy source link'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import BoundariesPanel from '$lib/components/BoundariesPanel.svelte';
|
import BoundariesPanel from '$lib/components/BoundariesPanel.svelte';
|
||||||
import SignIn from '$lib/components/SignIn.svelte';
|
import SignIn from '$lib/components/SignIn.svelte';
|
||||||
import { auth, savedIds, refresh as refreshAuth } from '$lib/auth.svelte.js';
|
import { auth, savedIds, refresh as refreshAuth } from '$lib/auth.svelte.js';
|
||||||
|
import { trackVisit } from '$lib/analytics.js';
|
||||||
|
|
||||||
let moods = $state([]);
|
let moods = $state([]);
|
||||||
let topics = $state([]);
|
let topics = $state([]);
|
||||||
@@ -302,6 +303,7 @@
|
|||||||
dismissed = new Set(P.loadJSON(DISMISSED_KEY, []));
|
dismissed = new Set(P.loadJSON(DISMISSED_KEY, []));
|
||||||
history = P.loadJSON(HISTORY_KEY, []);
|
history = P.loadJSON(HISTORY_KEY, []);
|
||||||
refreshAuth(); // resolve any existing session (non-blocking)
|
refreshAuth(); // resolve any existing session (non-blocking)
|
||||||
|
trackVisit(); // anonymous, once/day
|
||||||
try {
|
try {
|
||||||
moods = await getJSON('/api/moods');
|
moods = await getJSON('/api/moods');
|
||||||
topics = (await getJSON('/api/categories')).topics;
|
topics = (await getJSON('/api/categories')).topics;
|
||||||
|
|||||||
@@ -336,6 +336,23 @@ class PrefsBody(BaseModel):
|
|||||||
prefs: dict = {}
|
prefs: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
class EventBody(BaseModel):
|
||||||
|
kind: str
|
||||||
|
article_id: int | None = None
|
||||||
|
visitor: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# The only event kinds we record (per the agreed vocabulary).
|
||||||
|
_EVENT_KINDS = {"visit", "open", "share_ub", "copy_source", "native_share", "source_click"}
|
||||||
|
|
||||||
|
|
||||||
|
def _visitor_hash(token: str | None) -> str:
|
||||||
|
token = (token or "").strip()[:200]
|
||||||
|
if not token:
|
||||||
|
return ""
|
||||||
|
return hashlib.sha256(f"{SESSION_SECRET}:{token}".encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
# --- App --------------------------------------------------------------------
|
# --- App --------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -666,6 +683,20 @@ def create_app() -> FastAPI:
|
|||||||
_kick_summary(aid, background_tasks) # generate for next time; page polls
|
_kick_summary(aid, background_tasks) # generate for next time; page polls
|
||||||
return HTMLResponse(share.render_share_page(dict(row), PUBLIC_BASE_URL, summary=summary))
|
return HTMLResponse(share.render_share_page(dict(row), PUBLIC_BASE_URL, summary=summary))
|
||||||
|
|
||||||
|
# --- Privacy-respecting first-party analytics -------------------------
|
||||||
|
|
||||||
|
@app.post("/api/events")
|
||||||
|
def record_event(body: EventBody) -> dict:
|
||||||
|
if body.kind in _EVENT_KINDS:
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO events (kind, article_id, visitor_hash, day) "
|
||||||
|
"VALUES (?, ?, ?, date('now'))",
|
||||||
|
(body.kind, body.article_id or 0, _visitor_hash(body.visitor)),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"ok": True} # always identical; dedup'd by the unique key
|
||||||
|
|
||||||
@app.get("/api/summary/{article_id}")
|
@app.get("/api/summary/{article_id}")
|
||||||
def article_summary(article_id: int, background_tasks: BackgroundTasks) -> dict:
|
def article_summary(article_id: int, background_tasks: BackgroundTasks) -> dict:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
|||||||
@@ -205,6 +205,24 @@ CREATE TABLE IF NOT EXISTS article_summaries (
|
|||||||
model TEXT,
|
model TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Privacy-respecting, first-party analytics. NO IP / user-agent / referrer / raw
|
||||||
|
-- URL. visitor_hash is a hash of a random localStorage token (never email/IP).
|
||||||
|
-- The UNIQUE key dedups to one row per (kind, article, visitor, day) — that both
|
||||||
|
-- caps volume and makes counts mean "distinct visitor-days". Groupings are derived
|
||||||
|
-- from article_id at query time, never stored here.
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
article_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
visitor_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
day TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (kind, article_id, visitor_hash, day)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_day ON events(day);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_article ON events(article_id);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,21 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None)
|
|||||||
}}
|
}}
|
||||||
go();
|
go();
|
||||||
}})();
|
}})();
|
||||||
|
</script>"""
|
||||||
|
|
||||||
|
# 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"""<script>
|
||||||
|
(function(){{
|
||||||
|
var b=document.querySelector('[data-src-click]'); if(!b) return;
|
||||||
|
b.addEventListener('click',function(){{
|
||||||
|
try{{
|
||||||
|
var v=localStorage.getItem('goodnews:visitor')||'';
|
||||||
|
var body=JSON.stringify({{kind:'source_click',article_id:{aid},visitor:v}});
|
||||||
|
if(navigator.sendBeacon) navigator.sendBeacon('/api/events',new Blob([body],{{type:'application/json'}}));
|
||||||
|
}}catch(e){{}}
|
||||||
|
}});
|
||||||
|
}})();
|
||||||
</script>"""
|
</script>"""
|
||||||
|
|
||||||
return f"""<!doctype html>
|
return f"""<!doctype html>
|
||||||
@@ -134,6 +149,7 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None)
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</main>
|
</main>
|
||||||
|
{src_click}
|
||||||
{poll}
|
{poll}
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</html>"""
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user