diff --git a/frontend/landing.html b/frontend/landing.html index cde7cf5..925128a 100644 --- a/frontend/landing.html +++ b/frontend/landing.html @@ -210,6 +210,14 @@ See How We Compare + + +
+ + screens deployed with ScreenTinker +
@@ -630,6 +638,19 @@ // replace it on the public marketing page with a hardcoded Contact Us // card. Other consumers of /api/subscription/plans (billing.js, // settings.js, admin.js) get the full list as before. + /* Screens deployed. Only the deployment that collects install statistics answers this; + everywhere else it 404s and the line stays hidden. Failures are silent by design — + a marketing page must not show a broken stat, and there is nothing a visitor could + do about it. */ + fetch('/api/public/stats') + .then(r => (r.ok ? r.json() : null)) + .then(s => { + if (!s || !(s.screens > 0)) return; + document.getElementById('deployed-count').textContent = s.screens.toLocaleString(); + document.getElementById('deployed-stat').hidden = false; + }) + .catch(() => {}); + fetch('/api/subscription/plans').then(r => r.json()).then(plans => { const grid = document.getElementById('pricingGrid'); const publicPlans = plans.filter(p => p.active && p.name !== 'enterprise'); diff --git a/server/routes/telemetry-collector.js b/server/routes/telemetry-collector.js new file mode 100644 index 0000000..5681a17 --- /dev/null +++ b/server/routes/telemetry-collector.js @@ -0,0 +1,68 @@ +'use strict'; + +/* + * Opt-in install statistics — the COLLECTOR side, plus the public aggregate the marketing + * page reads. + * + * Mounted only when TELEMETRY_COLLECTOR=1, so a normal self-hosted install exposes neither + * route. That gate is doing real work on both: the report endpoint is unauthenticated, and + * the aggregate would otherwise let any anonymous visitor read a private instance's screen + * count off its own landing page. + * + * A factory rather than a bare router so the database can be injected — which is what lets + * this be tested at all. It was previously inline in server.js and had no tests. + */ + +const express = require('express'); + +module.exports = function createTelemetryCollectorRouter(db) { + const router = express.Router(); + + /* + * Deliberately unauthenticated: a self-hosted instance has no credential with us, and + * issuing one would mean an enrolment handshake for what is a three-integer postcard. + * + * Upsert keyed on instance_id, so an install that reports daily occupies one row forever + * rather than 365 a year. Nothing here reads or stores the request IP — receiving one is + * unavoidable, logging it would quietly make a pseudonymous report an identifiable one. + */ + router.post('/telemetry/report', express.json({ limit: '2kb' }), (req, res) => { + const { instance_id: id, version, screen_count: screens } = req.body || {}; + // Validate rather than trust: this endpoint is open, so a malformed or hostile body must + // land as a 400, never as a row that poisons the count it exists to produce. + if (typeof id !== 'string' || !/^[0-9a-f-]{36}$/i.test(id)) return res.status(400).json({ error: 'bad instance_id' }); + if (version != null && (typeof version !== 'string' || version.length > 40)) return res.status(400).json({ error: 'bad version' }); + if (!Number.isInteger(screens) || screens < 0 || screens > 100000) return res.status(400).json({ error: 'bad screen_count' }); + db.prepare(`INSERT INTO telemetry_reports (instance_id, version, screen_count, first_seen, last_seen) + VALUES (?, ?, ?, strftime('%s','now'), strftime('%s','now')) + ON CONFLICT(instance_id) DO UPDATE SET + version = excluded.version, screen_count = excluded.screen_count, last_seen = excluded.last_seen`) + .run(id, version || null, screens); + res.json({ ok: true }); + }); + + /* + * The aggregate, for the marketing page. An aggregate across every install that reports, + * so it discloses nothing about any one of them. + * + * Cached, because this sits on a public landing page and the number moves in hours, not + * milliseconds. A scraper hitting it in a loop costs one query per interval, not per request. + */ + const STATS_TTL_MS = 5 * 60 * 1000; + let statsCache = { at: 0, body: null }; + + router.get('/public/stats', (req, res) => { + const now = Date.now(); + if (!statsCache.body || now - statsCache.at > STATS_TTL_MS) { + const row = db.prepare( + 'SELECT COUNT(*) AS installs, COALESCE(SUM(screen_count), 0) AS screens FROM telemetry_reports' + ).get(); + statsCache = { at: now, body: { screens: row.screens, installs: row.installs } }; + } + // Public and cacheable, but never for long by a shared cache: the number is meant to climb. + res.set('Cache-Control', 'public, max-age=300'); + res.json(statsCache.body); + }); + + return router; +}; diff --git a/server/server.js b/server/server.js index b4530da..2bdb847 100644 --- a/server/server.js +++ b/server/server.js @@ -978,31 +978,13 @@ app.get('/api/version', (req, res) => { app.use('/api/status', require('./routes/status')); /* - * Opt-in install statistics — COLLECTOR side. Inert unless TELEMETRY_COLLECTOR=1, so a normal - * self-hosted install never exposes this at all; only the deployment that gathers the numbers - * turns it on. Deliberately unauthenticated: a self-hosted instance has no credential with us, - * and issuing one would mean an enrolment handshake for what is a three-integer postcard. - * - * Upsert keyed on instance_id, so a install that reports daily occupies one row forever rather - * than 365 a year. Nothing here reads or stores the request IP — receiving one is unavoidable, - * logging it would quietly make a pseudonymous report an identifiable one. + * Opt-in install statistics — the COLLECTOR side, plus the public aggregate the marketing + * page reads. Both live in routes/telemetry-collector.js; both are mounted only when + * TELEMETRY_COLLECTOR=1, so a normal self-hosted install exposes neither. */ if (process.env.TELEMETRY_COLLECTOR === '1') { - app.post('/api/telemetry/report', express.json({ limit: '2kb' }), (req, res) => { - const { instance_id: id, version, screen_count: screens } = req.body || {}; - // Validate rather than trust: this endpoint is open, so a malformed or hostile body must - // land as a 400, never as a row that poisons the count it exists to produce. - if (typeof id !== 'string' || !/^[0-9a-f-]{36}$/i.test(id)) return res.status(400).json({ error: 'bad instance_id' }); - if (version != null && (typeof version !== 'string' || version.length > 40)) return res.status(400).json({ error: 'bad version' }); - if (!Number.isInteger(screens) || screens < 0 || screens > 100000) return res.status(400).json({ error: 'bad screen_count' }); - db.prepare(`INSERT INTO telemetry_reports (instance_id, version, screen_count, first_seen, last_seen) - VALUES (?, ?, ?, strftime('%s','now'), strftime('%s','now')) - ON CONFLICT(instance_id) DO UPDATE SET - version = excluded.version, screen_count = excluded.screen_count, last_seen = excluded.last_seen`) - .run(id, version || null, screens); - res.json({ ok: true }); - }); - console.log('[telemetry] collector enabled at POST /api/telemetry/report'); + app.use('/api', require('./routes/telemetry-collector')(db)); + console.log('[telemetry] collector enabled at POST /api/telemetry/report (+ GET /api/public/stats)'); } // #146 BILLING: Usage Report on its OWN route (NOT part of /api/status — billing is revenue diff --git a/server/test/telemetry-collector.test.js b/server/test/telemetry-collector.test.js new file mode 100644 index 0000000..bbdadcb --- /dev/null +++ b/server/test/telemetry-collector.test.js @@ -0,0 +1,94 @@ +'use strict'; + +// The collector and the public aggregate it feeds. Both were previously inline in server.js +// with no test at all — the endpoint that decides what a public marketing page claims, and the +// unauthenticated one anyone on the internet can POST to. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const express = require('express'); +const Database = require('better-sqlite3'); + +const db = new Database(':memory:'); +db.exec(`CREATE TABLE telemetry_reports ( + instance_id TEXT PRIMARY KEY, + version TEXT, + screen_count INTEGER NOT NULL, + first_seen INTEGER NOT NULL, + last_seen INTEGER NOT NULL +);`); + +const app = express(); +app.use('/api', require('../routes/telemetry-collector')(db)); +const server = app.listen(0); +let base; + +test.before(async () => { + await new Promise(r => (server.listening ? r() : server.once('listening', r))); + base = `http://127.0.0.1:${server.address().port}`; +}); +test.after(() => server.close()); + +const uuid = (n) => `0000000${n}-0000-4000-8000-00000000000${n}`.slice(0, 36).padEnd(36, '0'); +const report = (body) => fetch(`${base}/api/telemetry/report`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), +}); + +test('a well-formed report is accepted and stored', async () => { + const res = await report({ instance_id: uuid(1), version: '1.9.34', screen_count: 12 }); + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), { ok: true }); + const row = db.prepare('SELECT * FROM telemetry_reports WHERE instance_id = ?').get(uuid(1)); + assert.equal(row.screen_count, 12); + assert.equal(row.version, '1.9.34'); +}); + +test('reporting again updates the row rather than adding one', async () => { + // An install reports daily. If this ever inserted instead of updating, one install would + // occupy 365 rows a year and the public figure would count it 365 times. + await report({ instance_id: uuid(1), version: '1.9.35', screen_count: 20 }); + const n = db.prepare('SELECT COUNT(*) AS c FROM telemetry_reports WHERE instance_id = ?').get(uuid(1)).c; + assert.equal(n, 1, 'still a single row for this install'); + const row = db.prepare('SELECT * FROM telemetry_reports WHERE instance_id = ?').get(uuid(1)); + assert.equal(row.screen_count, 20, 'count is the latest reported'); + assert.equal(row.version, '1.9.35'); +}); + +test('hostile or malformed bodies are refused, not stored', async () => { + const before = db.prepare('SELECT COUNT(*) AS c FROM telemetry_reports').get().c; + const bad = [ + { instance_id: 'not-a-uuid', screen_count: 1 }, + { instance_id: uuid(2) }, // no count + { instance_id: uuid(2), screen_count: -1 }, + { instance_id: uuid(2), screen_count: 1e9 }, // absurd, would skew the total + { instance_id: uuid(2), screen_count: 1.5 }, // not an integer + { instance_id: uuid(2), screen_count: 1, version: 'v'.repeat(41) }, + {}, + ]; + for (const body of bad) { + const res = await report(body); + assert.equal(res.status, 400, `expected 400 for ${JSON.stringify(body)}`); + } + assert.equal(db.prepare('SELECT COUNT(*) AS c FROM telemetry_reports').get().c, before, + 'nothing rejected reached the table'); +}); + +test('the public aggregate sums screens across installs and names no one', async () => { + db.prepare(`INSERT INTO telemetry_reports (instance_id, version, screen_count, first_seen, last_seen) + VALUES (?,?,?,?,?)`).run(uuid(3), '1.9.34', 480, 1, 1); + const res = await fetch(`${base}/api/public/stats`); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.screens, 500, '20 + 480'); + assert.equal(body.installs, 2); + // The whole payload — no instance ids, no versions, nothing per-install. + assert.deepEqual(Object.keys(body).sort(), ['installs', 'screens']); + assert.match(res.headers.get('cache-control') || '', /max-age=300/); +}); + +test('the aggregate is cached, so a scraper cannot turn page views into queries', async () => { + db.prepare(`INSERT INTO telemetry_reports (instance_id, version, screen_count, first_seen, last_seen) + VALUES (?,?,?,?,?)`).run(uuid(4), '1.9.34', 999, 1, 1); + const body = await (await fetch(`${base}/api/public/stats`)).json(); + assert.equal(body.screens, 500, 'still the cached figure, not 1499'); +});