Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions scripts/metrics-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)`);
}
Expand Down
1 change: 1 addition & 0 deletions src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
46 changes: 46 additions & 0 deletions worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,37 @@ async function handleExport(request: Request, env: Env): Promise<Response> {
});
}

// ── 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<Response> {
// 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<Response> {
const url = new URL(request.url);
if (url.searchParams.get('key') !== env.ADMIN_KEY) {
Expand All @@ -165,11 +196,22 @@ async function handleStats(request: Request, env: Env): Promise<Response> {
"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' } },
);
Expand Down Expand Up @@ -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<Env>;
44 changes: 44 additions & 0 deletions worker/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading