diff --git a/scripts/metrics-snapshot.ts b/scripts/metrics-snapshot.ts index 3cbb7ff..2596e4c 100644 --- a/scripts/metrics-snapshot.ts +++ b/scripts/metrics-snapshot.ts @@ -84,6 +84,12 @@ if (process.env.STATS_KEY) { for (const g of stats.active_by_source_platform || []) { addRow('subscribers_active', `${g.source}/${g.platform || 'none'}`, g.count); } + // Daily active installs via updater check-ins (#111 Phase 2). Only the + // last 7 days — weekly runs overlap, dedupe on (date, metric, label). + for (const d of (stats.updater_checkins_by_day || []).slice(0, 7)) { + addRow('updater_uniques', d.day, d.uniques); + addRow('updater_checkins', d.day, d.total); + } } else { console.warn(`[metrics] stats endpoint -> ${res.status} (skipping subscriber counts)`); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e615814..c23cb32 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -46,6 +46,7 @@ }, "updater": { "endpoints": [ + "https://pullread.com/api/updater/latest.json", "https://github.com/shellen/pullread/releases/latest/download/latest.json" ], "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDZBNDM3MTJBMEE5OUQwNEEKUldSSzBKa0tLbkZEYW5UUFRoc1gwSnlVMk5JaUIrMHBYL0pRblBkR1lLaWd3bkRxRFJjNjlDMk8K" diff --git a/worker/src/index.ts b/worker/src/index.ts index 58163da..ae1573f 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -147,6 +147,37 @@ async function handleExport(request: Request, env: Env): Promise { }); } +// ── Updater check-ins (#111 Phase 2) ───────────────────── +// The app's updater fetches latest.json through this endpoint, giving a +// daily active-install count without telemetry: one row per (day, salted +// IP hash), then a redirect to the real manifest on GitHub. No user agent, +// no version, no raw IP is stored. + +const UPDATER_MANIFEST_URL = + 'https://github.com/shellen/pullread/releases/latest/download/latest.json'; + +const CHECKINS_SCHEMA = + 'CREATE TABLE IF NOT EXISTS updater_checkins (' + + 'day TEXT NOT NULL, ip_hash TEXT NOT NULL, count INTEGER NOT NULL DEFAULT 1, ' + + 'PRIMARY KEY (day, ip_hash))'; + +async function handleUpdaterManifest(request: Request, env: Env): Promise { + // Counting must never block updates — redirect even if D1 hiccups. + try { + await env.DB.exec(CHECKINS_SCHEMA); + const day = new Date().toISOString().slice(0, 10); + const ip = request.headers.get('cf-connecting-ip') || 'unknown'; + const ipHash = (await hmacHex(env.HMAC_SECRET, `checkin:${ip}`)).slice(0, 32); + await env.DB.prepare( + 'INSERT INTO updater_checkins (day, ip_hash) VALUES (?, ?) ' + + 'ON CONFLICT(day, ip_hash) DO UPDATE SET count = count + 1', + ).bind(day, ipHash).run(); + } catch { + // swallow — the redirect below is the contract + } + return Response.redirect(UPDATER_MANIFEST_URL, 302); +} + async function handleStats(request: Request, env: Env): Promise { const url = new URL(request.url); if (url.searchParams.get('key') !== env.ADMIN_KEY) { @@ -165,11 +196,22 @@ async function handleStats(request: Request, env: Env): Promise { "SELECT strftime('%Y-%W', created_at) AS week, source, COUNT(*) AS count FROM subscribers GROUP BY week, source ORDER BY week", ).all(); + // Updater check-ins: uniques = distinct installs seen that day (#111) + let checkins: unknown[] = []; + try { + await env.DB.exec(CHECKINS_SCHEMA); + const rows = await env.DB.prepare( + 'SELECT day, COUNT(*) AS uniques, SUM(count) AS total FROM updater_checkins GROUP BY day ORDER BY day DESC LIMIT 30', + ).all(); + checkins = rows.results; + } catch { /* table empty/unavailable — report nothing rather than fail stats */ } + return new Response( JSON.stringify({ totals: { total: totals?.total ?? 0, active: totals?.active ?? 0 }, active_by_source_platform: bySourcePlatform.results, signups_by_week: byWeek.results, + updater_checkins_by_day: checkins, }), { headers: { 'Content-Type': 'application/json' } }, ); @@ -199,6 +241,10 @@ export default { return handleStats(request, env); } + if (url.pathname === '/api/updater/latest.json') { + return handleUpdaterManifest(request, env); + } + return new Response('Not found', { status: 404 }); }, } satisfies ExportedHandler; diff --git a/worker/test/index.test.ts b/worker/test/index.test.ts index 5114def..4551d73 100644 --- a/worker/test/index.test.ts +++ b/worker/test/index.test.ts @@ -280,3 +280,47 @@ describe('GET /api/stats', () => { expect(res.status).toBe(403); }); }); + +// ── GET /api/updater/latest.json ───────────────────────── + +describe('GET /api/updater/latest.json', () => { + test('redirects to the GitHub manifest and records a check-in', async () => { + const res = await SELF.fetch('https://fake.host/api/updater/latest.json', { + headers: { 'cf-connecting-ip': '203.0.113.7' }, + redirect: 'manual', + }); + expect(res.status).toBe(302); + expect(res.headers.get('Location')).toBe( + 'https://github.com/shellen/pullread/releases/latest/download/latest.json', + ); + + const rows = await env.DB.prepare('SELECT day, ip_hash, count FROM updater_checkins').all(); + expect(rows.results.length).toBe(1); + expect(rows.results[0].count).toBe(1); + // Raw IP never stored — only a salted hash + expect(String(rows.results[0].ip_hash)).not.toContain('203.0.113.7'); + }); + + test('same IP same day increments count, not uniques; new IP adds a row', async () => { + const opts = (ip: string) => ({ headers: { 'cf-connecting-ip': ip }, redirect: 'manual' as const }); + await SELF.fetch('https://fake.host/api/updater/latest.json', opts('203.0.113.7')); + await SELF.fetch('https://fake.host/api/updater/latest.json', opts('203.0.113.7')); + await SELF.fetch('https://fake.host/api/updater/latest.json', opts('198.51.100.9')); + + const rows = await env.DB.prepare( + 'SELECT COUNT(*) AS uniques, SUM(count) AS total FROM updater_checkins', + ).first(); + expect(rows.uniques).toBe(2); + expect(rows.total).toBe(3); + }); + + test('stats endpoint reports check-ins by day', async () => { + await SELF.fetch('https://fake.host/api/updater/latest.json', { + headers: { 'cf-connecting-ip': '203.0.113.7' }, redirect: 'manual', + }); + const res = await SELF.fetch('https://fake.host/api/stats?key=test-admin-key'); + const body = await res.json(); + expect(body.updater_checkins_by_day.length).toBe(1); + expect(body.updater_checkins_by_day[0].uniques).toBe(1); + }); +});