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

+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