diff --git a/package.json b/package.json index a303f58..42a599b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proddash", - "version": "1.10.1", + "version": "1.10.2", "private": true, "description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client", "license": "UNLICENSED", diff --git a/server.js b/server.js index 288ceea..a6765c9 100644 --- a/server.js +++ b/server.js @@ -956,9 +956,21 @@ async function fetchArchive() { signal: AbortSignal.timeout(30000), headers: { 'User-Agent': `ProdDash/${SHELL_VERSION}` }, }); - if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${REPO.owner}/${REPO.repo}@${REPO.branch}`); + const where = `${REPO.owner}/${REPO.repo}@${REPO.branch}`; + // A forge that wants a sign-in does not refuse: it redirects the download + // to its login page, which arrives as a 200 of HTML. Say that, not + // zlib's "incorrect header check". + if (res.redirected && /\/user\/login\b|\/login\b/.test(new URL(res.url).pathname)) { + throw new Error(`${FORGE.base} requires a sign-in to read ${where} — turn off "require sign-in to view" in Gitea, or make the repo readable without one`); + } + if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${where}`); const gz = Buffer.from(await res.arrayBuffer()); if (gz.length > MAX_ARCHIVE_BYTES) throw new Error('archive is unexpectedly large'); + if (gz.length < 2 || gz[0] !== 0x1f || gz[1] !== 0x8b) { + const type = String(res.headers.get('content-type') || 'unknown type').split(';')[0].trim(); + const looksHtml = /^\s*<(!doctype|html)/i.test(gz.subarray(0, 64).toString('utf8')); + throw new Error(`${FORGE.base} answered with ${looksHtml ? 'a web page' : type} instead of the ${where} archive — check the forge address and that the repo is readable without a sign-in`); + } const { files, comment } = parseTar(zlib.gunzipSync(gz)); const stripped = new Map(); // drop the archive's single top-level folder for (const [p, data] of files) { diff --git a/tools/update-check.test.js b/tools/update-check.test.js new file mode 100644 index 0000000..3407c7c --- /dev/null +++ b/tools/update-check.test.js @@ -0,0 +1,176 @@ +'use strict'; + +/** + * The update check against a forge that misbehaves: the admin page must say + * what went wrong in words, never zlib's "incorrect header check". + * + * node tools/update-check.test.js + * + * Plain Node, no dependencies. Starts a mock forge whose answer to the + * branch-archive download can be switched at runtime, starts server.js on a + * spare port pointed at it, and reads the shell status the admin page shows + * after each kind of answer: + * + * - a Gitea that requires a sign-in redirects the download to its login + * page (a 200 of HTML) — the case that hit both booths on 2026-09-29; + * - a captive portal or a wrong address answers HTML outright; + * - a plain HTTP error; + * - and a real archive, which must still parse and offer the update. + * + * Exits non-zero on the first failure and always stops what it started. + */ + +const assert = require('node:assert/strict'); +const { spawn, execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const http = require('node:http'); +const net = require('node:net'); +const os = require('node:os'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); + +function freePort() { + return new Promise((resolve, reject) => { + const probe = net.createServer(); + probe.listen(0, '127.0.0.1', () => { + const { port } = probe.address(); + probe.close(() => resolve(port)); + }); + probe.on('error', reject); + }); +} + +async function waitFor(url, ms = 15000) { + const deadline = Date.now() + ms; + let last = ''; + while (Date.now() < deadline) { + try { + const r = await fetch(url); + if (r.ok) return; + last = 'HTTP ' + r.status; + } catch (err) { + last = err.message; + } + await new Promise((r) => setTimeout(r, 200)); + } + throw new Error('server did not come up: ' + last); +} + +/** A branch archive the way a forge would build one: one top-level folder. */ +function buildArchive(dir) { + const top = path.join(dir, 'ProdDash-main'); + fs.mkdirSync(path.join(top, 'modules', 'demo'), { recursive: true }); + fs.writeFileSync(path.join(top, 'package.json'), JSON.stringify({ name: 'proddash', version: '9.9.9' })); + fs.writeFileSync(path.join(top, 'modules', 'demo', 'module.json'), JSON.stringify({ id: 'demo', name: 'Demo', version: '1.0.0', client: 'client.js' })); + fs.writeFileSync(path.join(top, 'modules', 'demo', 'client.js'), 'export default function () {}\n'); + const out = path.join(dir, 'main.tar.gz'); + execFileSync('tar', ['-czf', out, '-C', dir, 'ProdDash-main']); + return fs.readFileSync(out); +} + +(async () => { + const work = fs.mkdtempSync(path.join(os.tmpdir(), 'proddash-update-test-')); + const dataDir = path.join(work, 'data'); + fs.mkdirSync(dataDir); + const archive = buildArchive(path.join(work, 'forge')); + + // ── the mock forge ── + let mode = 'ok'; + const LOGIN_PAGE = '\nSign inSign in to continue'; + const forge = http.createServer((req, res) => { + const url = new URL(req.url, 'http://forge.invalid'); + if (url.pathname === '/user/login') { + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + return res.end(LOGIN_PAGE); + } + if (url.pathname !== '/michael/ProdDash/archive/main.tar.gz') { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + return res.end('not here'); + } + switch (mode) { + case 'login-redirect': + res.writeHead(303, { Location: '/user/login?redirect_to=' + encodeURIComponent(url.pathname) }); + return res.end(); + case 'html': + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + return res.end('You must log in to this network first'); + case 'notfound': + res.writeHead(404, { 'Content-Type': 'text/plain' }); + return res.end('no such branch'); + default: + res.writeHead(200, { 'Content-Type': 'application/gzip' }); + return res.end(archive); + } + }); + const forgePort = await freePort(); + await new Promise((r) => forge.listen(forgePort, '127.0.0.1', r)); + const forgeBase = `http://127.0.0.1:${forgePort}`; + + // ── ProdDash, pointed at it ── + const port = await freePort(); + const child = spawn(process.execPath, ['server.js'], { + cwd: ROOT, + env: { + ...process.env, + PRODDASH_DATA_DIR: dataDir, + PORT: String(port), + PRODDASH_FORGE: forgeBase, + PRODDASH_REPO: 'michael/ProdDash', + PRODDASH_NO_REMOTE_CHECK: '1', + PRODDASH_LAUNCHER: '1', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let log = ''; + child.stdout.on('data', (d) => { log += d; }); + child.stderr.on('data', (d) => { log += d; }); + const base = `http://127.0.0.1:${port}`; + + /** What the admin page shows after a forced check. Admin is open: a fresh data dir has no PIN. */ + const check = async (m) => { + mode = m; + const r = await fetch(base + '/api/admin/catalog?refresh=1'); + assert.equal(r.status, 200, `catalog: HTTP ${r.status}`); + const body = await r.json(); + return body.shell; + }; + + let failed = false; + try { + await waitFor(base + '/api/theme'); + + let shell = await check('login-redirect'); + assert.match(shell.remote.error, /requires a sign-in/, 'a login redirect is named as such: ' + shell.remote.error); + assert.match(shell.remote.error, /require sign-in to view/, 'and the Gitea setting to look at is named'); + assert.doesNotMatch(shell.remote.error, /header check/, 'never zlib\'s words'); + assert.equal(shell.updateAvailable, false); + + shell = await check('html'); + assert.match(shell.remote.error, /answered with a web page instead of the michael\/ProdDash@main archive/, shell.remote.error); + + shell = await check('notfound'); + assert.match(shell.remote.error, /HTTP 404 fetching michael\/ProdDash@main/, shell.remote.error); + + shell = await check('ok'); + assert.equal(shell.remote.error, '', 'a real archive clears the error: ' + shell.remote.error); + assert.equal(shell.remote.version, '9.9.9', 'and is read'); + assert.equal(shell.updateAvailable, true, 'and offered'); + + // Back to a broken forge: the last good read is not mistaken for a current one. + shell = await check('login-redirect'); + assert.match(shell.remote.error, /requires a sign-in/); + + console.log('update check: every check passed'); + } catch (err) { + failed = true; + console.error('FAIL:', err.message); + console.error('--- last of the server log ---\n' + log.slice(-2000)); + } finally { + child.kill('SIGTERM'); + forge.close(); + await new Promise((r) => setTimeout(r, 400)); + fs.rmSync(work, { recursive: true, force: true }); + } + process.exit(failed ? 1 : 0); +})();