Compare commits

...
1 Commits
Author SHA1 Message Date
Waters ChurchandClaude Fable 5.1 601dc9e8b0 Update check: say what the forge answered, not zlib's "incorrect header check"
A Gitea that requires a sign-in does not refuse the branch archive: it
redirects the download to its login page, which arrives as a 200 of HTML,
and the admin page then reported zlib failing to unpack it — "incorrect
header check". Both booths showed exactly that on 29 Sept once the sign-in
requirement was switched on.

The updater now names the case: "… requires a sign-in to read
michael/ProdDash@main — turn off 'require sign-in to view' in Gitea, or
make the repo readable without one". Any other answer that is not a gzip
archive says what came back instead — a web page, or the content type —
and where to look.

tools/update-check.test.js drives the check against a mock forge: the login
redirect, a bare HTML answer, an HTTP error, and a real archive that must
still parse and be offered.

Shell 1.9.3.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 14:54:05 -04:00
3 changed files with 190 additions and 2 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "proddash",
"version": "1.9.2",
"version": "1.9.3",
"private": true,
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
"license": "UNLICENSED",
+13 -1
View File
@@ -910,9 +910,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) {
+176
View File
@@ -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 = '<!DOCTYPE html>\n<html><head><title>Sign in</title></head><body>Sign in to continue</body></html>';
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('<html><body>You must log in to this network first</body></html>');
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);
})();