Make the admin's buttons work in a browser

Every mutating form now carries the CSRF token as a hidden field, and the
upload route accepts it as a multipart field as well as a header. Driving the
real UI in Firefox found what the header-only tests could not: Create Draft,
Save, Publish, Delete and the up/down buttons all posted without a token and
were rejected. Adds npm run check-site, which verifies a running Site from the
outside and exits non-zero so it can gate a cutover.
This commit is contained in:
tanshu committed 2026-09-22 21:32:52 +05:30
1 parent f89bd63685
commit af083415a7
6 files changed
+299 -4

No files matched your search

+7
View File
@@ -22,6 +22,13 @@ which needs a human present.
then the playbook in `ansible-playbooks/menu-admin/` — its readme covers the
parallel run, the `site.env` step that gives the admin a password, and cutover.
After deploying, check the running site from the outside (read-only, exits
non-zero on failure, so it can gate a cutover):
```sh
npm run check-site -- https://menu-new.hopsngrains.com
```
## Layout
```
+1
View File
@@ -10,6 +10,7 @@
"start": "node src/server.ts",
"dev": "SECURE_COOKIES=false node --watch src/server.ts",
"seed": "node scripts/seed-dev.ts",
"check-site": "node scripts/check-site.ts",
"verify": "node src/cli/verify.ts",
"repair": "node src/cli/repair.ts",
"typecheck": "tsc --noEmit",
+125
View File
@@ -0,0 +1,125 @@
/**
* Checks a running Site from the outside, the way a customer and a phone would
* meet it. Read-only: it never signs in and never writes.
*
* npm run check-site -- https://menu-new.hopsngrains.com
*
* Exits non-zero if anything fails, so it can gate a cutover.
*/
const base = (process.argv[2] ?? 'http://127.0.0.1:3000').replace(/\/$/, '');
interface Result {
name: string;
ok: boolean;
detail: string;
}
const results: Result[] = [];
function record(name: string, ok: boolean, detail: string): void {
results.push({ name, ok, detail });
}
async function request(path: string): Promise<Response> {
return fetch(`${base}${path}`, { redirect: 'manual' });
}
async function checkPage(path: string, expected: string[]): Promise<string | null> {
let response: Response;
try {
response = await request(path);
} catch (error) {
record(`GET ${path}`, false, `unreachable: ${(error as Error).message}`);
return null;
}
const body = await response.text();
const missing = expected.filter((needle) => !body.includes(needle));
record(
`GET ${path}`,
response.status === 200 && missing.length === 0,
`status ${response.status}${missing.length === 0 ? '' : `, missing ${missing.join(', ')}`}`,
);
return body;
}
// --- The site answers at all -------------------------------------------------
const landing = await checkPage('/', ['<html', 'class="landing"']);
if (landing !== null && !/class="row"|The menus are being updated/.test(landing)) {
record('landing has menus or an empty state', false, 'neither Section rows nor the empty state');
}
await checkPage('/healthz', ['ok']);
await checkPage('/robots.txt', ['Disallow: /admin']);
await checkPage('/admin/login', ['type="password"']);
await checkPage('/assets/styles.css', ['--copper']);
await checkPage('/assets/app.js', ['data-zoom']);
await checkPage('/assets/fonts/archivo-latin.woff2', []);
const missingPage = await request('/definitely-not-here');
record('GET /definitely-not-here', missingPage.status === 404, `status ${missingPage.status}`);
// --- Each Section a customer can reach, and its images -----------------------
const sections = landing === null ? [] : [...landing.matchAll(/class="row" href="([^"]+)"/g)].map((m) => m[1]!);
if (sections.length === 0) {
record('at least one Section has a Current Menu Set', false, 'the landing page lists none');
}
for (const href of sections) {
const body = await checkPage(href, ['class="page"']);
if (body === null) continue;
const pages = [...body.matchAll(/data-page="(\d+)"/g)].map((match) => match[1]!);
record(`${href} shows Pages`, pages.length > 0, `${pages.length} page(s)`);
const media = body.match(/\/media\/[0-9a-f-]{36}\.[0-9a-f]+\.webp/);
if (media === null) {
record(`${href} links a Viewing image`, false, 'no media URL in the page');
continue;
}
const image = await request(media[0]);
const cache = image.headers.get('cache-control') ?? '';
record(
`GET ${media[0]}`,
image.status === 200 &&
(image.headers.get('content-type') ?? '').startsWith('image/webp') &&
cache.includes('immutable'),
`status ${image.status}, type ${image.headers.get('content-type')}, cache ${cache}`,
);
const jpeg = await request(media[0].replace(/\.webp$/, '.jpg'));
record(
`GET ${media[0].replace(/\.webp$/, '.jpg')}`,
jpeg.status === 200 && (jpeg.headers.get('content-type') ?? '').startsWith('image/jpeg'),
`status ${jpeg.status}, type ${jpeg.headers.get('content-type')}`,
);
const wrongExtension = await request(media[0].replace(/\.webp$/, '.jpeg'));
record(
'Originals are not reachable',
wrongExtension.status === 404,
`a non-variant extension gives ${wrongExtension.status}`,
);
const traversal = await request(`${media[0].replace('/media/', '/media/..%2Foriginals%2F')}`);
record(
'media paths cannot escape the set directory',
traversal.status === 404,
`status ${traversal.status}`,
);
}
// --- Report -----------------------------------------------------------------
const width = Math.max(...results.map((result) => result.name.length));
for (const result of results) {
console.log(`${result.ok ? 'ok ' : 'FAIL'} ${result.name.padEnd(width)} ${result.detail}`);
}
const failures = results.filter((result) => !result.ok);
console.log(`\n${results.length - failures.length}/${results.length} checks passed against ${base}`);
if (failures.length > 0) process.exitCode = 1;
+11
View File
@@ -90,6 +90,7 @@ export interface DashboardInput {
config: SiteConfig;
sets: readonly StoredSet[];
current: ReadonlyMap<string, StoredSet>;
csrf: string;
notice: string | null;
}
@@ -120,6 +121,7 @@ export function renderDashboard(input: DashboardInput): Html {
</ul>
</details>`}
<form class="newset" method="post" action="/admin/sets">
<input type="hidden" name="csrf" value="${input.csrf}" />
<input type="hidden" name="section" value="${section.slug}" />
${labelled(
'New Menu Set',
@@ -140,6 +142,7 @@ export function renderDashboard(input: DashboardInput): Html {
<header class="admin__bar">
<h1 class="admin__title">Menus</h1>
<form method="post" action="/admin/logout">
<input type="hidden" name="csrf" value="${input.csrf}" />
<button class="button" type="submit">Sign out</button>
</form>
</header>
@@ -175,16 +178,19 @@ export function renderEditor(input: EditorInput): Html {
${draft
? html`<span class="page-admin__actions">
<form method="post" action="/admin/sets/${set.id}/move">
<input type="hidden" name="csrf" value="${input.csrf}" />
<input type="hidden" name="pageId" value="${page.id}" />
<input type="hidden" name="direction" value="up" />
<button class="button button--icon" type="submit" title="Move up">↑</button>
</form>
<form method="post" action="/admin/sets/${set.id}/move">
<input type="hidden" name="csrf" value="${input.csrf}" />
<input type="hidden" name="pageId" value="${page.id}" />
<input type="hidden" name="direction" value="down" />
<button class="button button--icon" type="submit" title="Move down">↓</button>
</form>
<form method="post" action="/admin/sets/${set.id}/pages/${page.id}/delete">
<input type="hidden" name="csrf" value="${input.csrf}" />
<button class="button button--icon button--danger" type="submit" title="Remove page">✕</button>
</form>
</span>`
@@ -217,6 +223,7 @@ export function renderEditor(input: EditorInput): Html {
<h2 class="block__title">Details</h2>
${draft
? html`<form class="inline-form" method="post" action="/admin/sets/${set.id}/update">
<input type="hidden" name="csrf" value="${input.csrf}" />
${labelled('Label', html`<input type="text" name="label" value="${set.manifest.label}" required />`)}
${labelled(
'Valid until (optional)',
@@ -239,6 +246,7 @@ export function renderEditor(input: EditorInput): Html {
<p class="drop__status" data-upload-status role="status"></p>
</div>
<form method="post" action="/admin/sets/${set.id}/pages" enctype="multipart/form-data" class="no-js-upload">
<input type="hidden" name="csrf" value="${input.csrf}" />
<input type="file" name="pages" accept="image/*" multiple />
<button class="button" type="submit">Upload</button>
</form>
@@ -256,6 +264,7 @@ export function renderEditor(input: EditorInput): Html {
<div class="actions">
${draft
? html`<form method="post" action="/admin/sets/${set.id}/publish">
<input type="hidden" name="csrf" value="${input.csrf}" />
<button class="button button--primary" type="submit" ${set.manifest.pages.length === 0 ? 'disabled' : ''}>
Publish (${pageCountLabel(set.manifest.pages.length)})
</button>
@@ -264,10 +273,12 @@ export function renderEditor(input: EditorInput): Html {
: html`<a class="button" href="/${section.slug}/${set.slug}/">View live</a>`}
${set.state === 'published'
? html`<form method="post" action="/admin/sets/${set.id}/restore">
<input type="hidden" name="csrf" value="${input.csrf}" />
<button class="button" type="submit">Make current</button>
</form>`
: ''}
<form method="post" action="/admin/sets/${set.id}/delete" data-confirm="Delete this Menu Set and its Originals? This cannot be undone.">
<input type="hidden" name="csrf" value="${input.csrf}" />
<input type="hidden" name="confirm" value="yes" />
<button class="button button--danger" type="submit">Delete</button>
</form>
+19 -4
View File
@@ -165,6 +165,7 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
config,
sets: await store.list(),
current: await store.currentBySection(),
csrf: csrfToken(sessionToken(request), deps.sessionSecret),
notice,
}).value,
);
@@ -288,19 +289,31 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
// ---------- Pages ----------
app.post<{ Params: { id: string } }>('/admin/sets/:id/pages', async (request, reply) => {
if (!authed(request) || !sameOrigin(request) || !csrfOk(request)) {
return reply.code(403).send('Rejected.');
}
if (!authed(request) || !sameOrigin(request)) return reply.code(403).send('Rejected.');
const set = await store.get(request.params.id);
if (set.state !== 'draft') {
return reply.code(409).send({ error: 'A published Menu Set cannot be changed.' });
}
if (!request.isMultipart()) return reply.code(400).send('Expected a file upload.');
// The script sends the token as a header; a plain form sends it as a
// multipart field, which request.body never sees. Either is fine, but a
// file is only stored once one of them has been checked.
const token = sessionToken(request);
let authorised = verifyCsrf(token, deps.sessionSecret, request.headers['x-csrf']);
const added: string[] = [];
const failed: { file: string; error: string }[] = [];
for await (const part of request.parts()) {
if (part.type !== 'file') continue;
if (part.type === 'field') {
if (part.fieldname === 'csrf') {
authorised = authorised || verifyCsrf(token, deps.sessionSecret, part.value);
}
continue;
}
if (!authorised) {
await part.toBuffer().catch(() => undefined);
continue;
}
const filename = part.filename || 'upload';
try {
const data = await part.toBuffer();
@@ -319,6 +332,8 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
}
}
if (!authorised) return reply.code(403).send('Rejected.');
if (wantsJson(request)) return reply.send({ added: added.length, failed });
const notice =
failed.length === 0
+136
View File
@@ -491,6 +491,142 @@ describe('publishing, restoring and deleting', () => {
});
});
describe('forms as a browser sends them', () => {
// A real browser posts form fields, not an x-csrf header. These tests exist
// because the header path passed while every button in the UI was broken.
function csrfFrom(body: string): string {
const match = body.match(/name="csrf" value="([^"]+)"/);
assert.ok(match, 'the page carries no CSRF field');
return match[1]!;
}
const encoded = (fields: Record<string, string>) => ({
headers: { 'content-type': 'application/x-www-form-urlencoded' },
payload: new URLSearchParams(fields).toString(),
});
it('creates a Draft from the dashboard form, with no CSRF header', async () => {
const dashboard = await app.inject({ method: 'GET', url: '/admin', cookies: cookies() });
const response = await app.inject({
method: 'POST',
url: '/admin/sets',
cookies: cookies(),
...encoded({ csrf: csrfFrom(dashboard.body), section: 'bar', label: 'Bar Menu' }),
});
assert.equal(response.statusCode, 303);
assert.match(String(response.headers['location']), /^\/admin\/sets\//);
});
it('publishes from the editor form, with no CSRF header', async () => {
const { id } = await newSet();
await upload(id, 'one.jpg');
const editor = await app.inject({ method: 'GET', url: `/admin/sets/${id}`, cookies: cookies() });
const response = await app.inject({
method: 'POST',
url: `/admin/sets/${id}/publish`,
cookies: cookies(),
...encoded({ csrf: csrfFrom(editor.body) }),
});
assert.equal(response.statusCode, 303);
assert.equal(response.headers['location'], '/bar/');
});
it('moves a Page with the up button as a browser sends it', async () => {
const { id } = await newSet();
await upload(id, 'one.jpg');
await upload(id, 'two.jpg');
const before = (await app.menu.store.get(id)).manifest.pages.map((page) => page.id);
const editor = await app.inject({ method: 'GET', url: `/admin/sets/${id}`, cookies: cookies() });
const response = await app.inject({
method: 'POST',
url: `/admin/sets/${id}/move`,
cookies: cookies(),
headers: {
'content-type': 'application/x-www-form-urlencoded',
referer: `/admin/sets/${id}`,
},
payload: new URLSearchParams({
csrf: csrfFrom(editor.body),
pageId: before[1]!,
direction: 'up',
}).toString(),
});
assert.equal(response.statusCode, 303);
assert.deepEqual(
(await app.menu.store.get(id)).manifest.pages.map((page) => page.id),
[before[1], before[0]],
);
});
it('accepts the token as a multipart field, the way the no-JS upload form sends it', async () => {
const { id } = await newSet();
const editor = await app.inject({ method: 'GET', url: `/admin/sets/${id}`, cookies: cookies() });
const token = csrfFrom(editor.body);
const boundary = '----formboundary';
const payload = Buffer.concat([
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="csrf"\r\n\r\n${token}\r\n`,
),
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="pages"; filename="one.jpg"\r\nContent-Type: image/jpeg\r\n\r\n`,
),
pngBytes('one'),
Buffer.from(`\r\n--${boundary}--\r\n`),
]);
const response = await app.inject({
method: 'POST',
url: `/admin/sets/${id}/pages`,
cookies: cookies(),
headers: {
'content-type': `multipart/form-data; boundary=${boundary}`,
referer: `/admin/sets/${id}`,
},
payload,
});
assert.equal(response.statusCode, 303);
assert.equal((await app.menu.store.get(id)).manifest.pages.length, 1);
});
it('refuses a multipart upload that carries no token at all', async () => {
const { id } = await newSet();
const boundary = '----formboundary';
const payload = Buffer.concat([
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="pages"; filename="one.jpg"\r\nContent-Type: image/jpeg\r\n\r\n`,
),
pngBytes('one'),
Buffer.from(`\r\n--${boundary}--\r\n`),
]);
const response = await app.inject({
method: 'POST',
url: `/admin/sets/${id}/pages`,
cookies: cookies(),
headers: { 'content-type': `multipart/form-data; boundary=${boundary}` },
payload,
});
assert.equal(response.statusCode, 403);
assert.equal((await app.menu.store.get(id)).manifest.pages.length, 0);
});
it('puts a CSRF field in every form that mutates', async () => {
const { id } = await newSet('bar', 'Bar Menu');
await upload(id, 'one.jpg');
const dashboard = (await app.inject({ method: 'GET', url: '/admin', cookies: cookies() })).body;
const editor = (await app.inject({ method: 'GET', url: `/admin/sets/${id}`, cookies: cookies() })).body;
for (const [where, body] of [['dashboard', dashboard], ['editor', editor]] as const) {
const forms = [...body.matchAll(/<form\b[^>]*>[\s\S]*?<\/form>/g)].map((match) => match[0]);
assert.ok(forms.length > 0, `no forms found in the ${where}`);
for (const form of forms) {
assert.match(form, /name="csrf"/, `a ${where} form carries no CSRF field:\n${form}`);
}
}
});
});
describe('previewing a Draft', () => {
it('renders the customer page with a banner, and serves its media to the session', async () => {
const { id } = await newSet('bar', 'Bar Menu', '2026-06-26');