From 114dc453bb83914d39a8357dd1ad521b5003c8a4 Mon Sep 17 00:00:00 2001 From: screentinker Date: Fri, 14 Aug 2026 10:07:50 -0500 Subject: [PATCH] Show screens deployed on the landing page (#277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The number exists — every install that opts into sharing reports its screen count, and the collector has been keeping them since it went live. Nothing read them back out. GET /api/public/stats returns the aggregate: total screens and how many installs they came from. The landing page shows it under the hero and stays silent otherwise — hidden until a number arrives, so a self-hosted instance (where the route does not exist) and a brand-new one (where the count is zero) show nothing rather than an empty frame or a "0". Gated on TELEMETRY_COLLECTOR, the same flag as the collector, and the gate is doing real work here: without it, any anonymous visitor could read a private instance's screen count off its own landing page. Only the deployment that gathers the numbers may state them, and there the figure is a sum across every reporting install, so it discloses nothing about any one of them. Verified with the flag unset: both routes 404. Cached for five minutes. This sits on a public page and the number moves in hours, so a scraper in a loop costs one query per interval rather than one per request. Both endpoints moved out of server.js into routes/telemetry-collector.js as an injectable factory. They were inline and therefore untestable — an unauthenticated endpoint anyone on the internet can POST to, and the one that decides what a public page claims, with no test between them. Now covered: the upsert really updates (an install reporting daily must not become 365 rows and get counted 365 times), malformed and hostile bodies are refused without reaching the table, the aggregate carries no per-install detail, and the cache holds. 1676/1676 pass. --- frontend/landing.html | 21 ++++++ server/routes/telemetry-collector.js | 68 ++++++++++++++++++ server/server.js | 28 ++------ server/test/telemetry-collector.test.js | 94 +++++++++++++++++++++++++ 4 files changed, 188 insertions(+), 23 deletions(-) create mode 100644 server/routes/telemetry-collector.js create mode 100644 server/test/telemetry-collector.test.js 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 + + + @@ -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'); +});