Publish menus from the browser instead of scp

The admin signs in with the shared password, creates a Menu Set, drops photos
into it (shrunk in the browser first), reorders Pages by dragging or with
up/down buttons, previews the Draft exactly as customers would see it, and
publishes it over the previous set. Restore and delete close the loop. Mutations
need a session, a same-origin request and a CSRF token derived from the session;
a Draft's media is served only to a session holder.
This commit is contained in:
tanshu committed 2026-09-22 21:22:26 +05:30
1 parent 9cb4021229
commit 58ecfa0dc3
14 files changed
+1824 -15

No files matched your search

+21 -5
View File
@@ -6,10 +6,11 @@ photographed Pages, replacing the `scp`-a-JPG workflow. One instance per Site;
## Status ## Status
Built: Site config, the Menu Set store, the image pipeline, and the Built: Site config, the Menu Set store, the image pipeline, the
customer-facing renderer (landing page, Section pages, archived sets, media customer-facing renderer (landing page, Section pages, archived sets, media
route) with the approved design. Not yet built: the admin, the container and route) with the approved design, and the admin (sign in, create a set, upload
the Ansible role. and reorder Pages, preview, publish, restore, delete). Not yet built: the
container, the Ansible playbook and the deploy.
## Layout ## Layout
@@ -19,9 +20,10 @@ src/storage/manifest.ts Manifest type, validation, atomic JSON writes
src/storage/sets.ts The Menu Set store: create, add Pages, publish, restore, delete src/storage/sets.ts The Menu Set store: create, add Pages, publish, restore, delete
src/storage/validate.ts Boot validation: fatal problems and warnings src/storage/validate.ts Boot validation: fatal problems and warnings
src/images/pipeline.ts sharp variants: Original, Viewing image, Thumbnail src/images/pipeline.ts sharp variants: Original, Viewing image, Thumbnail
src/auth.ts scrypt password hash, signed session cookie, login throttle src/auth.ts scrypt password hash, session cookie, login throttle, CSRF
src/render/ HTML builder, document shell, the customer pages src/render/ HTML builder, document shell, the customer pages, the admin
src/routes/public.ts Landing, Section, archive and media routes src/routes/public.ts Landing, Section, archive and media routes
src/routes/admin.ts Sign in, dashboard, editor, upload, publish, restore, delete
src/server.ts buildApp (what tests drive) and main (what the container runs) src/server.ts buildApp (what tests drive) and main (what the container runs)
public/ Hand-written CSS, ~2KB of progressive-enhancement JS, Archivo public/ Hand-written CSS, ~2KB of progressive-enhancement JS, Archivo
config/ Per-Site config, one file per Site config/ Per-Site config, one file per Site
@@ -42,6 +44,20 @@ npm run seed # placeholder Pages in .dev/data, for design review
npm run dev # http://localhost:3000 against .dev npm run dev # http://localhost:3000 against .dev
``` ```
To sign in locally, put a password hash in `.dev/site.env`:
```sh
printf 'ADMIN_PASSWORD_HASH=%s\nSESSION_SECRET=%s\n' \
"$(node -e "import('./src/auth.ts').then(async m => process.stdout.write(await m.hashPassword('your password')))")" \
"$(node -e "process.stdout.write(require('node:crypto').randomBytes(32).toString('base64url'))")" \
> .dev/site.env
```
## Who can do what
`/` and `/<section>/` are public. `/admin` needs the password; a Draft's Pages
are served only to a signed-in session, and Originals have no route at all.
## Storage model ## Storage model
``` ```
+21
View File
@@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.1.2", "@fastify/cookie": "^11.1.2",
"@fastify/formbody": "^9.0.0",
"@fastify/multipart": "^10.1.2", "@fastify/multipart": "^10.1.2",
"@fastify/static": "^10.1.4", "@fastify/static": "^10.1.4",
"fastify": "^5.12.5", "fastify": "^5.12.5",
@@ -146,6 +147,26 @@
"fast-json-stringify": "^7.0.0" "fast-json-stringify": "^7.0.0"
} }
}, },
"node_modules/@fastify/formbody": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@fastify/formbody/-/formbody-9.0.0.tgz",
"integrity": "sha512-T/af26CSrUARBCvsEmv+DJLPfZlrRKESzqironxP1j7qzuLyKcoZtj6MuTGShuKx1THXugoie2oFbUJxXfGFzA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"fast-querystring": "^1.1.2",
"fastify-plugin": "^6.0.0"
}
},
"node_modules/@fastify/forwarded": { "node_modules/@fastify/forwarded": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.2.tgz", "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.2.tgz",
+1
View File
@@ -15,6 +15,7 @@
}, },
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.1.2", "@fastify/cookie": "^11.1.2",
"@fastify/formbody": "^9.0.0",
"@fastify/multipart": "^10.1.2", "@fastify/multipart": "^10.1.2",
"@fastify/static": "^10.1.4", "@fastify/static": "^10.1.4",
"fastify": "^5.12.5", "fastify": "^5.12.5",
+322
View File
@@ -0,0 +1,322 @@
/* The admin shares the customer tokens and stays out of the way: forms,
lists, and thumbnails good enough to judge a page by. */
.admin {
max-width: 62rem;
margin: 0 auto;
padding: 1.25rem 1rem 4rem;
}
.admin__bar {
display: flex;
align-items: baseline;
gap: 0.85rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--hairline);
}
.admin__title {
margin: 0;
font-size: var(--step-2);
font-weight: 600;
font-stretch: 116%;
}
.admin__section {
flex: 1 1 auto;
color: var(--ink-dim);
font-size: 0.9rem;
}
.block {
margin-top: 1.75rem;
}
.block__title {
margin: 0 0 0.5rem;
color: var(--ink-dim);
font-size: 0.85rem;
font-weight: 500;
letter-spacing: 0.02em;
}
.setlist {
margin: 0;
padding: 0;
list-style: none;
}
.setlist li {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
padding: 0.55rem 0;
border-bottom: 1px solid var(--hairline-soft);
}
.setlist__none {
color: var(--ink-dim);
font-size: 0.9rem;
}
.setlist__main {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0.5rem;
}
.setlist__label {
font-weight: 500;
}
.setlist__meta {
color: var(--ink-dim);
font-size: 0.85rem;
}
.setlist__actions {
display: flex;
gap: 0.35rem;
white-space: nowrap;
}
.pill {
padding: 0.1rem 0.4rem;
border: 1px solid var(--hairline);
color: var(--ink-dim);
font-size: 0.72rem;
text-transform: none;
}
.pill--current {
border-color: transparent;
background: var(--accent);
color: var(--steel);
font-weight: 600;
}
.pill--draft {
border-color: transparent;
background: var(--copper);
color: var(--steel);
font-weight: 600;
}
.button {
display: inline-block;
padding: 0.4rem 0.7rem;
border: 1px solid var(--hairline);
background: var(--rail);
color: var(--ink);
font: inherit;
font-size: 0.88rem;
cursor: pointer;
}
.button:hover {
border-color: var(--accent);
}
.button--primary {
border-color: transparent;
background: var(--accent);
color: var(--steel);
font-weight: 600;
}
.button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.button--danger {
border-color: color-mix(in srgb, #d06a5a 55%, transparent);
color: #eeb8ae;
}
.button--icon {
padding: 0.25rem 0.5rem;
font-size: 0.9rem;
}
.newset,
.inline-form {
display: flex;
align-items: flex-end;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 0.9rem;
}
.newset {
padding-top: 0.9rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
min-width: 12rem;
}
.field label {
display: flex;
flex-direction: column;
gap: 0.3rem;
color: var(--ink-dim);
font-size: 0.82rem;
}
.field input {
padding: 0.45rem 0.55rem;
border: 1px solid var(--hairline);
background: var(--steel);
color: var(--ink);
font: inherit;
font-size: 0.95rem;
}
.field__hint,
.muted {
color: var(--ink-dim);
font-size: 0.85rem;
}
.actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.drop {
padding: 1.1rem;
border: 1px dashed var(--hairline);
color: var(--ink-dim);
text-align: center;
}
.drop.is-over {
border-color: var(--accent);
color: var(--ink);
}
.drop__text {
margin: 0;
}
.drop__hint {
margin: 0.4rem 0 0;
font-size: 0.82rem;
}
.drop__status {
margin: 0.6rem 0 0;
min-height: 1.2em;
color: var(--accent);
font-size: 0.88rem;
}
.no-js-upload {
display: none;
}
.pages-admin {
display: grid;
gap: 0.5rem;
margin: 1rem 0 0;
padding: 0;
list-style: none;
}
.page-admin {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem;
background: var(--rail);
}
.page-admin.is-dragging {
opacity: 0.4;
}
.page-admin img {
display: block;
width: 64px;
height: auto;
background: var(--paper);
}
.page-admin__handle {
color: var(--ink-dim);
cursor: grab;
user-select: none;
}
.page-admin__meta {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
font-size: 0.82rem;
color: var(--ink-dim);
}
.page-admin__number {
color: var(--ink);
font-weight: 600;
}
.page-admin__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.page-admin__actions {
display: flex;
gap: 0.25rem;
}
.notice--error {
border-left-color: #d06a5a;
background: color-mix(in srgb, #d06a5a 18%, var(--rail));
}
.fold summary {
color: var(--ink-dim);
font-size: 0.85rem;
cursor: pointer;
}
/* Login */
.login {
max-width: 21rem;
margin: 14vh auto 0;
padding: 0 1rem;
}
.login__sub {
margin: 0.2rem 0 1.5rem;
color: var(--ink-dim);
}
.login form {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: flex-start;
}
.login .field {
width: 100%;
}
.login input {
width: 100%;
}
+202
View File
@@ -0,0 +1,202 @@
/* Admin enhancements. Every action here also has a form-post equivalent, so
losing this file costs convenience, not function. */
(function () {
'use strict';
var root = document.querySelector('[data-set-id]');
if (!root) return;
var setId = root.getAttribute('data-set-id');
var csrf = root.getAttribute('data-csrf');
var MAX_EDGE = 3000;
var JPEG_QUALITY = 0.92;
var drop = document.querySelector('[data-drop]');
var fileInput = document.querySelector('[data-file-input]');
var status = document.querySelector('[data-upload-status]');
var choose = document.querySelector('[data-choose]');
var list = document.querySelector('[data-pages-admin]');
var noJsForm = document.querySelector('.no-js-upload');
if (noJsForm) noJsForm.style.display = 'none';
Array.prototype.forEach.call(document.querySelectorAll('[data-confirm]'), function (form) {
form.addEventListener('submit', function (event) {
if (!window.confirm(form.getAttribute('data-confirm'))) event.preventDefault();
});
});
function say(message) {
if (status) status.textContent = message;
}
/* Shrink in the browser so a 20-page menu is quick to upload over restaurant
wifi. Photos the browser cannot decode (HEIC, say) go up untouched and are
rejected later with a message naming the file. */
function shrink(file) {
return new Promise(function (resolve) {
if (!/^image\/(jpeg|png|webp)$/.test(file.type) || typeof createImageBitmap !== 'function') {
return resolve(file);
}
createImageBitmap(file)
.then(function (bitmap) {
var scale = Math.min(1, MAX_EDGE / Math.max(bitmap.width, bitmap.height));
if (scale === 1 && file.type === 'image/jpeg' && file.size < 4 * 1024 * 1024) {
bitmap.close();
return resolve(file);
}
var canvas = document.createElement('canvas');
canvas.width = Math.round(bitmap.width * scale);
canvas.height = Math.round(bitmap.height * scale);
canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height);
bitmap.close();
canvas.toBlob(
function (blob) {
if (!blob || blob.size >= file.size) return resolve(file);
var name = file.name.replace(/\.[^.]+$/, '') + '.jpg';
resolve(new File([blob], name, { type: 'image/jpeg' }));
},
'image/jpeg',
JPEG_QUALITY,
);
})
.catch(function () {
resolve(file);
});
});
}
function uploadOne(file) {
var body = new FormData();
body.append('pages', file, file.name);
return fetch('/admin/sets/' + setId + '/pages', {
method: 'POST',
headers: { 'x-csrf': csrf, accept: 'application/json' },
body: body,
}).then(function (response) {
return response.json().then(function (payload) {
if (!response.ok) throw new Error(payload.error || 'upload failed');
if (payload.failed && payload.failed.length > 0) {
throw new Error(payload.failed.map(function (item) { return item.error; }).join('; '));
}
return payload;
});
});
}
function upload(files) {
var queue = Array.prototype.slice.call(files);
if (queue.length === 0) return;
var failures = [];
function next(index) {
if (index >= queue.length) {
if (failures.length === 0) {
say('Uploaded ' + queue.length + '. Reloading…');
window.location.reload();
} else {
say('Failed: ' + failures.join('; '));
}
return;
}
say('Shrinking and uploading ' + (index + 1) + ' of ' + queue.length + '…');
shrink(queue[index])
.then(uploadOne)
.then(function () {
next(index + 1);
})
.catch(function (error) {
failures.push(error.message);
next(index + 1);
});
}
next(0);
}
if (choose && fileInput) {
choose.addEventListener('click', function () {
fileInput.click();
});
}
if (fileInput) {
fileInput.addEventListener('change', function () {
upload(fileInput.files);
});
}
if (drop) {
['dragenter', 'dragover'].forEach(function (type) {
drop.addEventListener(type, function (event) {
event.preventDefault();
drop.classList.add('is-over');
});
});
['dragleave', 'drop'].forEach(function (type) {
drop.addEventListener(type, function (event) {
event.preventDefault();
drop.classList.remove('is-over');
});
});
drop.addEventListener('drop', function (event) {
if (event.dataTransfer && event.dataTransfer.files) upload(event.dataTransfer.files);
});
}
/* Drag to reorder. The up/down buttons remain for touch and no-JS. */
function items() {
return Array.prototype.slice.call(list.querySelectorAll('[data-page-id]'));
}
function renumber() {
items().forEach(function (item, index) {
var number = item.querySelector('.page-admin__number');
if (number) number.textContent = String(index + 1);
});
}
function saveOrder() {
var ids = items().map(function (item) {
return item.getAttribute('data-page-id');
});
say('Saving order…');
return fetch('/admin/sets/' + setId + '/reorder', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-csrf': csrf, accept: 'application/json' },
body: JSON.stringify({ pageIds: ids }),
})
.then(function (response) {
if (!response.ok) throw new Error('could not save the order');
say('Order saved.');
})
.catch(function (error) {
say(error.message);
});
}
if (list) {
var dragged = null;
items().forEach(function (item) {
item.addEventListener('dragstart', function (event) {
dragged = item;
item.classList.add('is-dragging');
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
});
item.addEventListener('dragend', function () {
item.classList.remove('is-dragging');
dragged = null;
renumber();
saveOrder();
});
item.addEventListener('dragover', function (event) {
if (!dragged || dragged === item) return;
event.preventDefault();
var rect = item.getBoundingClientRect();
var after = event.clientY > rect.top + rect.height / 2;
list.insertBefore(dragged, after ? item.nextSibling : item);
});
});
}
})();
+6
View File
@@ -236,6 +236,12 @@ a {
text-decoration: underline; text-decoration: underline;
} }
.notice--admin {
max-width: none;
border-left-color: var(--accent);
background: color-mix(in srgb, var(--accent) 22%, var(--rail));
}
/* ---------- The Pages ---------- */ /* ---------- The Pages ---------- */
.pages { .pages {
+22 -2
View File
@@ -94,8 +94,7 @@ export function verifySessionToken(token: string | undefined, secret: string, no
return Number.isSafeInteger(expiry) && expiry > now.getTime(); return Number.isSafeInteger(expiry) && expiry > now.getTime();
} }
export interface ThrottleVerdict { export interface ThrottleVerdict { allowed: boolean;
allowed: boolean;
retryAfterSeconds: number; retryAfterSeconds: number;
} }
@@ -147,3 +146,24 @@ export class LoginThrottle {
this.failures.delete(key); this.failures.delete(key);
} }
} }
/**
* A stateless CSRF token derived from the session cookie: a cross-site request
* cannot produce it, because it cannot read the httpOnly cookie.
*/
export function csrfToken(sessionToken: string | undefined, secret: string): string {
return createHmac('sha256', secret).update(`csrf:${sessionToken ?? 'anonymous'}`).digest('base64url');
}
export function verifyCsrf(
sessionToken: string | undefined,
secret: string,
provided: unknown,
): boolean {
if (typeof provided !== 'string' || provided === '') return false;
const providedBuffer = Buffer.from(provided);
const expectedBuffer = Buffer.from(csrfToken(sessionToken, secret));
return (
providedBuffer.length === expectedBuffer.length && timingSafeEqual(providedBuffer, expectedBuffer)
);
}
+278
View File
@@ -0,0 +1,278 @@
/**
* The admin's server-rendered pages: a login form, a dashboard of Sections
* and their Menu Sets, and the editor for one set. Forms post and redirect, so
* the whole thing works without JavaScript; the script only adds drag-to-reorder
* and browser-side pre-shrinking of uploads.
*/
import { html, Html, join } from './html.ts';
import { renderDocument } from './layout.ts';
import { pageCountLabel, setDateLabel } from './pages.ts';
import type { SectionConfig, SiteConfig } from '../config.ts';
import { variantFileNames } from '../storage/manifest.ts';
import type { StoredSet } from '../storage/sets.ts';
export interface AdminShellInput {
config: SiteConfig;
title: string;
bodyClass: string;
body: Html;
scripts: readonly string[];
}
function shell(input: AdminShellInput): Html {
return renderDocument({
config: input.config,
title: input.title,
bodyClass: `admin-page ${input.bodyClass}`,
description: null,
noindex: true,
styles: ['/assets/admin.css'],
scripts: input.scripts,
body: input.body,
});
}
function labelled(
label: string,
control: Html,
hint: string | null = null,
): Html {
return html`<span class="field">
<label>${label}${control}</label>
${hint === null ? '' : html`<span class="field__hint">${hint}</span>`}
</span>`;
}
export function renderLogin(config: SiteConfig, error: string | null): Html {
return shell({
config,
title: 'Sign in',
bodyClass: 'admin-login',
scripts: [],
body: html`<main class="login">
<h1 class="wordmark">${config.brand.name}</h1>
<p class="login__sub">Menus</p>
${error === null ? '' : html`<p class="notice notice--error">${error}</p>`}
<form method="post" action="/admin/login">
${labelled('Password', html`<input type="password" name="password" autocomplete="current-password" autofocus required />`)}
<button class="button button--primary" type="submit">Sign in</button>
</form>
</main>`,
});
}
function setRow(section: SectionConfig, set: StoredSet, kind: 'current' | 'draft' | 'archive'): Html {
const pill =
kind === 'current'
? html`<span class="pill pill--current">Current</span>`
: kind === 'draft'
? html`<span class="pill pill--draft">Draft</span>`
: html`<span class="pill">Archive</span>`;
const actions =
kind === 'draft'
? html`<a class="button" href="/admin/sets/${set.id}">Edit</a>
<a class="button" href="/admin/sets/${set.id}/preview">Preview</a>`
: html`<a class="button" href="/admin/sets/${set.id}">Open</a>
<a class="button" href="/${section.slug}/${set.slug}/">View</a>`;
return html`<li>
<span class="setlist__main">
${pill}
<span class="setlist__label">${set.manifest.label}</span>
<span class="setlist__meta">${setDateLabel(set.manifest)} · ${pageCountLabel(set.manifest.pages.length)}</span>
</span>
<span class="setlist__actions">${actions}</span>
</li>`;
}
export interface DashboardInput {
config: SiteConfig;
sets: readonly StoredSet[];
current: ReadonlyMap<string, StoredSet>;
notice: string | null;
}
export function renderDashboard(input: DashboardInput): Html {
const { config } = input;
const bySection = (slug: string) => input.sets.filter((set) => set.section === slug);
const blocks = config.sections.map((section) => {
const all = bySection(section.slug);
const current = input.current.get(section.slug);
const drafts = all.filter((set) => set.state === 'draft');
const archives = all
.filter((set) => set.state === 'published' && set.manifest.id !== current?.manifest.id)
.sort((a, b) => (b.manifest.publishedAt ?? '').localeCompare(a.manifest.publishedAt ?? ''));
return html`<section class="block">
<h2 class="block__title">${section.label}</h2>
<ul class="setlist">
${current === undefined ? html`<li class="setlist__none">No menu published yet.</li>` : setRow(section, current, 'current')}
${join(drafts.map((set) => setRow(section, set, 'draft')))}
</ul>
${archives.length === 0
? ''
: html`<details class="fold">
<summary>${archives.length === 1 ? '1 older menu' : `${archives.length} older menus`}</summary>
<ul class="setlist">
${join(archives.map((set) => setRow(section, set, 'archive')))}
</ul>
</details>`}
<form class="newset" method="post" action="/admin/sets">
<input type="hidden" name="section" value="${section.slug}" />
${labelled(
'New Menu Set',
html`<input type="text" name="label" value="${section.label} Menu" required />`,
)}
${labelled('Valid until (optional)', html`<input type="date" name="validUntil" />`)}
<button class="button" type="submit">Create Draft</button>
</form>
</section>`;
});
return shell({
config,
title: 'Menus',
bodyClass: 'admin-dashboard',
scripts: [],
body: html`<main class="admin">
<header class="admin__bar">
<h1 class="admin__title">Menus</h1>
<form method="post" action="/admin/logout">
<button class="button" type="submit">Sign out</button>
</form>
</header>
${input.notice === null ? '' : html`<p class="notice">${input.notice}</p>`}
${blocks}
</main>`,
});
}
export interface EditorInput {
config: SiteConfig;
section: SectionConfig;
set: StoredSet;
csrf: string;
notice: string | null;
error: string | null;
}
export function renderEditor(input: EditorInput): Html {
const { config, section, set } = input;
const draft = set.state === 'draft';
const pageItems = set.manifest.pages.map((page, index) => {
const names = variantFileNames(page);
return html`<li class="page-admin" draggable="${draft ? 'true' : 'false'}" data-page-id="${page.id}">
<span class="page-admin__handle" aria-hidden="true">⠿</span>
<img src="/media/${names.thumb}" alt="" width="64" />
<span class="page-admin__meta">
<span class="page-admin__number">${index + 1}</span>
<span class="page-admin__name">${page.originalName}</span>
<span class="page-admin__dims">${page.width} × ${page.height}</span>
</span>
${draft
? html`<span class="page-admin__actions">
<form method="post" action="/admin/sets/${set.id}/move">
<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="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">
<button class="button button--icon button--danger" type="submit" title="Remove page">✕</button>
</form>
</span>`
: ''}
</li>`;
});
return shell({
config,
title: `${set.manifest.label} · ${section.label}`,
bodyClass: 'admin-editor',
scripts: ['/assets/admin.js'],
body: html`<main class="admin" data-set-id="${set.id}" data-csrf="${input.csrf}">
<header class="admin__bar">
<h1 class="admin__title">${set.manifest.label}</h1>
<span class="admin__section">${section.label}</span>
<a class="button" href="/admin">All menus</a>
</header>
${input.notice === null ? '' : html`<p class="notice">${input.notice}</p>`}
${input.error === null ? '' : html`<p class="notice notice--error">${input.error}</p>`}
${set.state === 'published'
? html`<p class="notice">
This menu is published at <a href="/${section.slug}/${set.slug}/">/${section.slug}/${set.slug}/</a>.
A published menu never changes; to change it, create a new Menu Set.
</p>`
: ''}
<section class="block">
<h2 class="block__title">Details</h2>
${draft
? html`<form class="inline-form" method="post" action="/admin/sets/${set.id}/update">
${labelled('Label', html`<input type="text" name="label" value="${set.manifest.label}" required />`)}
${labelled(
'Valid until (optional)',
html`<input type="date" name="validUntil" value="${set.manifest.validUntil ?? ''}" />`,
)}
<button class="button" type="submit">Save</button>
</form>`
: html`<p class="muted">${setDateLabel(set.manifest)}</p>`}
</section>
${draft
? html`<section class="block">
<h2 class="block__title">Pages</h2>
<div class="drop" data-drop>
<input type="file" accept="image/jpeg,image/png,image/webp,image/*" multiple data-file-input hidden />
<p class="drop__text">Drop photos here, or <button class="button" type="button" data-choose>choose files</button></p>
<p class="drop__hint">
JPEG, PNG or WebP. Photos are shrunk in the browser to ${3000}px before they are uploaded.
</p>
<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="file" name="pages" accept="image/*" multiple />
<button class="button" type="submit">Upload</button>
</form>
<ul class="pages-admin" data-pages-admin>
${pageItems.length === 0 ? html`<li class="setlist__none">No pages yet.</li>` : join(pageItems)}
</ul>
</section>`
: html`<section class="block">
<h2 class="block__title">Pages</h2>
<ul class="pages-admin">${join(pageItems)}</ul>
</section>`}
<section class="block">
<h2 class="block__title">${draft ? 'Publish' : 'Manage'}</h2>
<div class="actions">
${draft
? html`<form method="post" action="/admin/sets/${set.id}/publish">
<button class="button button--primary" type="submit" ${set.manifest.pages.length === 0 ? 'disabled' : ''}>
Publish (${pageCountLabel(set.manifest.pages.length)})
</button>
</form>
<a class="button" href="/admin/sets/${set.id}/preview">Preview</a>`
: 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">
<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="confirm" value="yes" />
<button class="button button--danger" type="submit">Delete</button>
</form>
</div>
</section>
</main>`,
});
}
+5 -2
View File
@@ -9,7 +9,9 @@ export interface DocumentInput {
body: Html; body: Html;
description: string | null; description: string | null;
noindex: boolean; noindex: boolean;
script: boolean; /** Extra stylesheets, on top of the customer stylesheet every page gets. */
styles: readonly string[];
scripts: readonly string[];
} }
export function renderDocument(input: DocumentInput): Html { export function renderDocument(input: DocumentInput): Html {
@@ -38,6 +40,7 @@ export function renderDocument(input: DocumentInput): Html {
crossorigin crossorigin
/> />
<link rel="stylesheet" href="/assets/styles.css" /> <link rel="stylesheet" href="/assets/styles.css" />
${input.styles.map((href) => html`<link rel="stylesheet" href="${href}" />`)}
<style> <style>
:root { :root {
--steel: ${theme.steel}; --steel: ${theme.steel};
@@ -50,7 +53,7 @@ export function renderDocument(input: DocumentInput): Html {
</head> </head>
<body class="${input.bodyClass}"> <body class="${input.bodyClass}">
${input.body} ${input.body}
${input.script ? html`<script src="/assets/app.js" defer></script>` : ''} ${input.scripts.map((src) => html`<script src="${src}" defer></script>`)}
</body> </body>
</html> </html>
`; `;
+19 -5
View File
@@ -153,7 +153,8 @@ export function renderLanding(input: LandingInput): Html {
bodyClass: 'landing-page', bodyClass: 'landing-page',
description: config.brand.tagline, description: config.brand.tagline,
noindex: false, noindex: false,
script: false, styles: [],
scripts: [],
body: html`<main class="landing"> body: html`<main class="landing">
<header class="masthead"> <header class="masthead">
<h1 class="wordmark">${config.brand.name}</h1> <h1 class="wordmark">${config.brand.name}</h1>
@@ -174,6 +175,8 @@ export interface SectionInput {
published: ReadonlySet<string>; published: ReadonlySet<string>;
current: boolean; current: boolean;
archives: readonly StoredSet[]; archives: readonly StoredSet[];
/** Draft preview for the admin: adds a banner and skips the archive fold. */
preview: boolean;
} }
export function renderSectionPage(input: SectionInput): Html { export function renderSectionPage(input: SectionInput): Html {
@@ -186,14 +189,23 @@ export function renderSectionPage(input: SectionInput): Html {
${renderRail(config, section.slug, input.published)} ${renderRail(config, section.slug, input.published)}
<div class="pour" aria-hidden="true"><span class="pour__fill" data-pour></span></div> <div class="pour" aria-hidden="true"><span class="pour__fill" data-pour></span></div>
</header> </header>
${input.preview
? html`<p class="notice notice--admin">
Preview of a Draft — customers cannot see this yet.
<a href="/admin/sets/${set.id}">Back to the editor</a>
</p>`
: ''}
<h2 class="section-heading"> <h2 class="section-heading">
<span class="section-heading__label">${set.manifest.label}</span> <span class="section-heading__label">${set.manifest.label}</span>
<span class="section-heading__date">${setDateLabel(set.manifest)}</span> <span class="section-heading__date">${setDateLabel(set.manifest)}</span>
</h2> </h2>
${input.current ? '' : html`<p class="notice">This is an older menu. <a href="${currentHref}">See the current menu</a>.</p>`} ${input.current || input.preview
? ''
: html`<p class="notice">This is an older menu. <a href="${currentHref}">See the current menu</a>.</p>`}
${renderPages(section, set.manifest.pages)} ${renderPages(section, set.manifest.pages)}
<footer class="foot"> <footer class="foot">
${renderPageIndex(total)} ${renderOlderMenus(section, input.archives, currentHref)} ${renderPageIndex(total)}
${input.preview ? '' : renderOlderMenus(section, input.archives, currentHref)}
</footer> </footer>
<button class="counter" type="button" data-counter hidden> <button class="counter" type="button" data-counter hidden>
<span data-counter-current>1</span><span class="counter__total">/ ${total}</span> <span data-counter-current>1</span><span class="counter__total">/ ${total}</span>
@@ -205,7 +217,8 @@ export function renderSectionPage(input: SectionInput): Html {
bodyClass: 'section-page', bodyClass: 'section-page',
description: config.brand.tagline, description: config.brand.tagline,
noindex: !input.current, noindex: !input.current,
script: true, styles: [],
scripts: ['/assets/app.js'],
body, body,
}); });
} }
@@ -228,7 +241,8 @@ export function renderNotFound(config: SiteConfig, live: readonly SectionConfig[
bodyClass: 'landing-page', bodyClass: 'landing-page',
description: null, description: null,
noindex: true, noindex: true,
script: false, styles: [],
scripts: [],
body: html`<main class="landing"> body: html`<main class="landing">
<header class="masthead"> <header class="masthead">
<h1 class="wordmark">${config.brand.name}</h1> <h1 class="wordmark">${config.brand.name}</h1>
+378
View File
@@ -0,0 +1,378 @@
/**
* The admin: sign in, create a Menu Set, upload and order Pages, preview,
* publish, restore and delete. Everything is a form post that redirects, so
* the admin works without JavaScript; the script only adds drag-to-reorder and
* browser-side pre-shrinking.
*/
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import {
LoginThrottle,
SESSION_COOKIE,
createSessionToken,
csrfToken,
verifyCsrf,
verifyPassword,
verifySessionToken,
} from '../auth.ts';
import { sectionBySlug, type SiteConfig } from '../config.ts';
import { renderDashboard, renderEditor, renderLogin } from '../render/admin.ts';
import { renderSectionPage } from '../render/pages.ts';
import { UnsupportedImageError } from '../images/pipeline.ts';
import type { SetStore, StoredSet } from '../storage/sets.ts';
import { StoreError } from '../storage/sets.ts';
import { sessionCookieAttributes, SESSION_TTL_MS } from '../server.ts';
const HTML = 'text/html; charset=utf-8';
export interface AdminRouteDeps {
config: SiteConfig;
store: SetStore;
sessionSecret: string;
adminPasswordHash: string | null;
secureCookies: boolean;
throttle?: LoginThrottle | undefined;
}
function sendHtml(reply: FastifyReply, body: string, status = 200): FastifyReply {
return reply.code(status).type(HTML).send(body);
}
/** Same-origin check: the second belt beside the CSRF token. */
export function sameOrigin(request: FastifyRequest): boolean {
const site = request.headers['sec-fetch-site'];
if (typeof site === 'string' && site !== 'same-origin' && site !== 'none') return false;
const origin = request.headers['origin'];
if (typeof origin !== 'string') return true;
try {
return new URL(origin).host === request.headers['host'];
} catch {
return false;
}
}
export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRouteDeps): Promise<void> {
const { config, store } = deps;
const throttle = deps.throttle ?? new LoginThrottle();
const sessionToken = (request: FastifyRequest): string | undefined =>
request.cookies[SESSION_COOKIE];
const authed = (request: FastifyRequest): boolean =>
verifySessionToken(sessionToken(request), deps.sessionSecret);
const csrfOk = (request: FastifyRequest): boolean => {
const provided =
(request.headers['x-csrf'] as string | undefined) ??
(request.body as Record<string, unknown> | undefined)?.['csrf'];
return verifyCsrf(sessionToken(request), deps.sessionSecret, provided);
};
const wantsJson = (request: FastifyRequest): boolean =>
(request.headers['accept'] ?? '').includes('application/json');
const back = (request: FastifyRequest, reply: FastifyReply, notice: string | null) => {
const target = request.headers['referer'];
const base = typeof target === 'string' ? new URL(target, 'http://x').pathname : '/admin';
const url = notice === null ? base : `${base}?notice=${encodeURIComponent(notice)}`;
return reply.redirect(url, 303);
};
const guard = async (
request: FastifyRequest,
reply: FastifyReply,
): Promise<{ set: StoredSet } | null> => {
if (!authed(request)) {
reply.redirect('/admin/login', 303);
return null;
}
if (!sameOrigin(request) || !csrfOk(request)) {
reply.code(403).type(HTML).send('That request was rejected. Reload the page and try again.');
return null;
}
const id = (request.params as { id?: string }).id;
if (id === undefined) {
reply.code(400).send();
return null;
}
try {
return { set: await store.get(id) };
} catch (error) {
if (error instanceof StoreError) {
reply.code(404).type(HTML).send('That Menu Set no longer exists.');
return null;
}
throw error;
}
};
const fail = (request: FastifyRequest, reply: FastifyReply, error: unknown): FastifyReply => {
const message = error instanceof Error ? error.message : 'Something went wrong.';
const status = error instanceof StoreError ? error.status : 400;
if (wantsJson(request)) return reply.code(status).send({ error: message });
return reply.code(status).type(HTML).send(message);
};
// ---------- Sign in ----------
app.get('/admin/login', async (request, reply) => {
if (authed(request)) return reply.redirect('/admin', 303);
return sendHtml(reply, renderLogin(config, null).value);
});
app.post('/admin/login', async (request, reply) => {
if (!sameOrigin(request)) return reply.code(403).send('Rejected.');
const verdict = throttle.check(request.ip);
if (!verdict.allowed) {
return reply
.code(429)
.header('retry-after', String(verdict.retryAfterSeconds))
.type(HTML)
.send(renderLogin(config, `Too many attempts. Try again in ${verdict.retryAfterSeconds}s.`).value);
}
const password = (request.body as { password?: string } | undefined)?.password ?? '';
const hash = deps.adminPasswordHash;
const ok = hash !== null && password !== '' && (await verifyPassword(password, hash));
if (!ok) {
throttle.recordFailure(request.ip);
return reply
.code(401)
.type(HTML)
.send(renderLogin(config, 'That password was not right.').value);
}
throttle.reset(request.ip);
reply.setCookie(
SESSION_COOKIE,
createSessionToken(deps.sessionSecret, SESSION_TTL_MS),
sessionCookieAttributes(deps.secureCookies),
);
return reply.redirect('/admin', 303);
});
app.post('/admin/logout', async (_request, reply) => {
reply.clearCookie(SESSION_COOKIE, { path: '/' });
return reply.redirect('/admin/login', 303);
});
// ---------- Dashboard ----------
app.get('/admin/', async (request, reply) => {
if (!authed(request)) return reply.redirect('/admin/login', 303);
const notice = (request.query as { notice?: string }).notice ?? null;
return sendHtml(
reply,
renderDashboard({
config,
sets: await store.list(),
current: await store.currentBySection(),
notice,
}).value,
);
});
// ---------- Create, edit, publish ----------
app.post('/admin/sets', async (request, reply) => {
if (!authed(request)) return reply.redirect('/admin/login', 303);
if (!sameOrigin(request) || !csrfOk(request)) {
return reply.code(403).type(HTML).send('That request was rejected. Reload the page and try again.');
}
const body = request.body as { section?: string; label?: string; validUntil?: string };
const section = sectionBySlug(config, body.section ?? '');
if (section === undefined) return reply.code(400).send('Unknown Section.');
try {
const created = await store.create(section.slug, {
label: body.label ?? `${section.label} Menu`,
validUntil: body.validUntil === undefined || body.validUntil === '' ? null : body.validUntil,
});
return reply.redirect(`/admin/sets/${created.id}`, 303);
} catch (error) {
return fail(request, reply, error);
}
});
app.get<{ Params: { id: string } }>('/admin/sets/:id', async (request, reply) => {
if (!authed(request)) return reply.redirect('/admin/login', 303);
let set: StoredSet;
try {
set = await store.get(request.params.id);
} catch {
return reply.code(404).type(HTML).send('That Menu Set no longer exists.');
}
const section = sectionBySlug(config, set.section);
if (section === undefined) return reply.code(404).send('Unknown Section.');
const query = request.query as { notice?: string; error?: string };
return sendHtml(
reply,
renderEditor({
config,
section,
set,
csrf: csrfToken(sessionToken(request), deps.sessionSecret),
notice: query.notice ?? null,
error: query.error ?? null,
}).value,
);
});
app.get<{ Params: { id: string } }>('/admin/sets/:id/preview', async (request, reply) => {
if (!authed(request)) return reply.redirect('/admin/login', 303);
const set = await store.get(request.params.id);
const section = sectionBySlug(config, set.section);
if (section === undefined) return reply.code(404).send('Unknown Section.');
const current = (await store.currentBySection()).get(section.slug);
return sendHtml(
reply,
renderSectionPage({
config,
section,
set,
published: new Set((await store.currentBySection()).keys()),
current: current?.id === set.id,
preview: set.state === 'draft',
archives: [],
}).value,
);
});
app.post<{ Params: { id: string } }>('/admin/sets/:id/update', async (request, reply) => {
const guarded = await guard(request, reply);
if (guarded === null) return reply;
const body = request.body as { label?: string; validUntil?: string };
try {
await store.update(guarded.set.id, {
label: body.label ?? guarded.set.manifest.label,
validUntil:
body.validUntil === undefined || body.validUntil === '' ? null : body.validUntil,
});
} catch (error) {
return fail(request, reply, error);
}
return back(request, reply, 'Saved.');
});
app.post<{ Params: { id: string } }>('/admin/sets/:id/publish', async (request, reply) => {
const guarded = await guard(request, reply);
if (guarded === null) return reply;
try {
const published = await store.publish(guarded.set.id);
return reply.redirect(`/${published.section}/`, 303);
} catch (error) {
return fail(request, reply, error);
}
});
app.post<{ Params: { id: string } }>('/admin/sets/:id/restore', async (request, reply) => {
const guarded = await guard(request, reply);
if (guarded === null) return reply;
try {
const restored = await store.restore(guarded.set.id);
return reply.redirect(`/${restored.section}/`, 303);
} catch (error) {
return fail(request, reply, error);
}
});
app.post<{ Params: { id: string } }>('/admin/sets/:id/delete', async (request, reply) => {
const guarded = await guard(request, reply);
if (guarded === null) return reply;
const body = request.body as { confirm?: string };
try {
await store.remove(guarded.set.id, { confirmCurrent: body.confirm === 'yes' });
} catch (error) {
return fail(request, reply, error);
}
return reply.redirect('/admin', 303);
});
// ---------- 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.');
}
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.');
const added: string[] = [];
const failed: { file: string; error: string }[] = [];
for await (const part of request.parts()) {
if (part.type !== 'file') continue;
const filename = part.filename || 'upload';
try {
const data = await part.toBuffer();
const page = await store.addPage(set.id, { data, originalName: filename });
added.push(page.id);
} catch (error) {
const message =
error instanceof UnsupportedImageError
? error.message
: error instanceof StoreError
? error.message
: error instanceof Error && 'code' in error && error.code === 'FST_REQ_FILE_TOO_LARGE'
? `${filename} is larger than the upload limit`
: `${filename} could not be uploaded`;
failed.push({ file: filename, error: message });
}
}
if (wantsJson(request)) return reply.send({ added: added.length, failed });
const notice =
failed.length === 0
? `Uploaded ${added.length} page${added.length === 1 ? '' : 's'}.`
: `${added.length} uploaded, ${failed.length} failed: ${failed.map((item) => item.error).join('; ')}`;
return back(request, reply, notice);
});
app.post<{ Params: { id: string; pageId: string } }>(
'/admin/sets/:id/pages/:pageId/delete',
async (request, reply) => {
const guarded = await guard(request, reply);
if (guarded === null) return reply;
try {
await store.removePage(guarded.set.id, request.params.pageId);
} catch (error) {
return fail(request, reply, error);
}
return back(request, reply, 'Page removed.');
},
);
app.post<{ Params: { id: string } }>('/admin/sets/:id/move', async (request, reply) => {
const guarded = await guard(request, reply);
if (guarded === null) return reply;
const body = request.body as { pageId?: string; direction?: string };
const ids = guarded.set.manifest.pages.map((page) => page.id);
const index = ids.indexOf(body.pageId ?? '');
if (index === -1) return reply.code(404).send('No such Page.');
const target = body.direction === 'up' ? index - 1 : index + 1;
if (target < 0 || target >= ids.length) return back(request, reply, null);
const reordered = [...ids];
const [moved] = reordered.splice(index, 1);
reordered.splice(target, 0, moved!);
try {
await store.reorder(guarded.set.id, reordered);
} catch (error) {
return fail(request, reply, error);
}
return back(request, reply, null);
});
app.post<{ Params: { id: string } }>('/admin/sets/:id/reorder', async (request, reply) => {
const guarded = await guard(request, reply);
if (guarded === null) return reply;
const body = request.body as { pageIds?: unknown };
if (!Array.isArray(body.pageIds) || body.pageIds.some((id) => typeof id !== 'string')) {
return reply.code(400).send({ error: 'pageIds must be an array of Page ids' });
}
try {
await store.reorder(guarded.set.id, body.pageIds as string[]);
} catch (error) {
return fail(request, reply, error);
}
return reply.send({ ok: true });
});
}
+2
View File
@@ -82,6 +82,7 @@ export async function registerPublicRoutes(
set: current, set: current,
published: await publishedSections(), published: await publishedSections(),
current: true, current: true,
preview: false,
archives: await store.archives(section.slug), archives: await store.archives(section.slug),
}).value, }).value,
); );
@@ -115,6 +116,7 @@ export async function registerPublicRoutes(
set, set,
published: await publishedSections(), published: await publishedSections(),
current: current?.id === set.id, current: current?.id === set.id,
preview: false,
archives, archives,
}).value, }).value,
); );
+22 -1
View File
@@ -8,14 +8,17 @@ import { readFile } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import Fastify, { type FastifyError, type FastifyInstance } from 'fastify'; import Fastify, { type FastifyError, type FastifyInstance } from 'fastify';
import cookie from '@fastify/cookie'; import cookie from '@fastify/cookie';
import formbody from '@fastify/formbody';
import multipart from '@fastify/multipart';
import fastifyStatic from '@fastify/static'; import fastifyStatic from '@fastify/static';
import { parseSiteConfig, type SiteConfig } from './config.ts'; import { parseSiteConfig, type SiteConfig } from './config.ts';
import { SESSION_COOKIE, sessionTtl } from './auth.ts'; import { SESSION_COOKIE, sessionTtl, type LoginThrottle } from './auth.ts';
import { createImagePipeline } from './images/pipeline.ts'; import { createImagePipeline } from './images/pipeline.ts';
import { SetStore, type ImagePipeline } from './storage/sets.ts'; import { SetStore, type ImagePipeline } from './storage/sets.ts';
import { hasFatal, validateSite } from './storage/validate.ts'; import { hasFatal, validateSite } from './storage/validate.ts';
import { renderNotFound } from './render/pages.ts'; import { renderNotFound } from './render/pages.ts';
import { registerPublicRoutes } from './routes/public.ts'; import { registerPublicRoutes } from './routes/public.ts';
import { registerAdminRoutes } from './routes/admin.ts';
import { readEnvFile } from './env.ts'; import { readEnvFile } from './env.ts';
export const SESSION_TTL_MS = 8 * 60 * 60 * 1000; export const SESSION_TTL_MS = 8 * 60 * 60 * 1000;
@@ -48,6 +51,8 @@ export interface AppOptions {
pipeline?: ImagePipeline; pipeline?: ImagePipeline;
/** Test seam: defaults to the real clock. */ /** Test seam: defaults to the real clock. */
now?: () => Date; now?: () => Date;
/** Test seam: a shared throttle so rate-limit tests are deterministic. */
throttle?: LoginThrottle;
} }
export async function buildApp(options: AppOptions): Promise<FastifyInstance> { export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
@@ -58,6 +63,13 @@ export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
}); });
await app.register(cookie); await app.register(cookie);
await app.register(formbody);
await app.register(multipart, {
limits: {
fileSize: options.config.limits.maxUploadBytes,
files: options.config.limits.maxPagesPerSet,
},
});
await app.register(fastifyStatic, { await app.register(fastifyStatic, {
root: options.assetsDir, root: options.assetsDir,
prefix: '/assets/', prefix: '/assets/',
@@ -80,6 +92,15 @@ export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
sessionSecret: options.sessionSecret, sessionSecret: options.sessionSecret,
}); });
await registerAdminRoutes(app, {
config: options.config,
store,
sessionSecret: options.sessionSecret,
adminPasswordHash: options.adminPasswordHash,
secureCookies: options.secureCookies,
throttle: options.throttle,
});
app.setNotFoundHandler(async (_request, reply) => { app.setNotFoundHandler(async (_request, reply) => {
const current = await store.currentBySection(); const current = await store.currentBySection();
const live = options.config.sections.filter((section) => current.has(section.slug)); const live = options.config.sections.filter((section) => current.has(section.slug));
+525
View File
@@ -0,0 +1,525 @@
import { strict as assert } from 'node:assert';
import { rm } from 'node:fs/promises';
import path from 'node:path';
import { afterEach, beforeEach, describe, it } from 'node:test';
import type { FastifyInstance } from 'fastify';
import { LoginThrottle, SESSION_COOKIE, createSessionToken, csrfToken, hashPassword } from '../src/auth.ts';
import { parseSiteConfig } from '../src/config.ts';
import { buildApp } from '../src/server.ts';
import { FakePipeline, pngBytes, tempDir, testClock } from './support/harness.ts';
const ASSETS = path.join(import.meta.dirname, '..', 'public');
const SECRET = 'admin-test-secret';
const PASSWORD = 'a good long password';
let dataDir: string;
let app: FastifyInstance;
let session: string;
let csrf: string;
const CLOCK = testClock('2026-05-01T06:00:00.000Z');
async function build(throttle?: LoginThrottle): Promise<FastifyInstance> {
const built = await buildApp({
config: parseSiteConfig({
site: 'menu.example.test',
brand: { name: 'Hops n Grains' },
sections: [
{ slug: 'food', label: 'Food', order: 1 },
{ slug: 'bar', label: 'Bar', order: 2 },
],
}),
dataDir,
sessionSecret: SECRET,
adminPasswordHash: await hashPassword(PASSWORD),
assetsDir: ASSETS,
secureCookies: false,
logLevel: 'silent',
pipeline: new FakePipeline(),
now: CLOCK.now,
...(throttle === undefined ? {} : { throttle }),
});
await built.ready();
return built;
}
beforeEach(async () => {
dataDir = await tempDir('admin');
app = await build();
session = createSessionToken(SECRET, 60 * 60 * 1000, new Date());
csrf = csrfToken(session, SECRET);
});
afterEach(async () => {
await app.close();
await rm(dataDir, { recursive: true, force: true });
});
const cookies = () => ({ [SESSION_COOKIE]: session });
async function newSet(section = 'bar', label = 'Bar Menu', validUntil: string | null = null) {
const response = await app.inject({
method: 'POST',
url: '/admin/sets',
cookies: cookies(),
headers: { 'x-csrf': csrf },
payload: { section, label, ...(validUntil === null ? {} : { validUntil }) },
});
const location = String(response.headers['location'] ?? '');
return { response, id: location.replace('/admin/sets/', '') };
}
async function upload(id: string, name: string) {
const boundary = '----menuadminboundary';
const payload = Buffer.concat([
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="pages"; filename="${name}"\r\nContent-Type: image/jpeg\r\n\r\n`,
),
pngBytes(name),
Buffer.from(`\r\n--${boundary}--\r\n`),
]);
return app.inject({
method: 'POST',
url: `/admin/sets/${id}/pages`,
cookies: cookies(),
headers: {
'x-csrf': csrf,
accept: 'application/json',
'content-type': `multipart/form-data; boundary=${boundary}`,
},
payload,
});
}
describe('signing in', () => {
it('sends an anonymous visitor to the login form', async () => {
const dashboard = await app.inject({ method: 'GET', url: '/admin' });
assert.equal(dashboard.statusCode, 303);
assert.equal(dashboard.headers['location'], '/admin/login');
const login = await app.inject({ method: 'GET', url: '/admin/login' });
assert.equal(login.statusCode, 200);
assert.match(login.body, /type="password"/);
});
it('rejects a wrong password and accepts the right one', async () => {
const wrong = await app.inject({
method: 'POST',
url: '/admin/login',
payload: { password: 'not it' },
});
assert.equal(wrong.statusCode, 401);
assert.equal(wrong.headers['set-cookie'], undefined);
assert.match(wrong.body, /not right/);
const right = await app.inject({
method: 'POST',
url: '/admin/login',
payload: { password: PASSWORD },
});
assert.equal(right.statusCode, 303);
const cookie = String(right.headers['set-cookie']);
assert.match(cookie, /menu_session=/);
assert.match(cookie, /HttpOnly/);
assert.match(cookie, /SameSite=Lax/);
});
it('throttles repeated failures per IP', async () => {
const throttled = await build(new LoginThrottle({ maxFailures: 2, windowMs: 60_000 }));
for (let attempt = 0; attempt < 2; attempt += 1) {
await throttled.inject({ method: 'POST', url: '/admin/login', payload: { password: 'no' } });
}
const blocked = await throttled.inject({
method: 'POST',
url: '/admin/login',
payload: { password: 'no' },
});
assert.equal(blocked.statusCode, 429);
assert.ok(Number(blocked.headers['retry-after']) > 0);
await throttled.close();
});
it('signs out', async () => {
const response = await app.inject({ method: 'POST', url: '/admin/logout', cookies: cookies() });
assert.equal(response.statusCode, 303);
assert.match(String(response.headers['set-cookie']), /menu_session=;/);
});
});
describe('guarding the admin', () => {
it('needs a session for the dashboard and the editor', async () => {
assert.equal((await app.inject({ method: 'GET', url: '/admin' })).statusCode, 303);
const { id } = await newSet();
assert.equal((await app.inject({ method: 'GET', url: `/admin/sets/${id}` })).statusCode, 303);
});
it('needs a CSRF token on mutations', async () => {
const missing = await app.inject({
method: 'POST',
url: '/admin/sets',
cookies: cookies(),
payload: { section: 'bar', label: 'Bar Menu' },
});
assert.equal(missing.statusCode, 403);
const wrong = await app.inject({
method: 'POST',
url: '/admin/sets',
cookies: cookies(),
headers: { 'x-csrf': 'not-the-token' },
payload: { section: 'bar', label: 'Bar Menu' },
});
assert.equal(wrong.statusCode, 403);
// A token minted for a different session must not work either.
const otherSession = createSessionToken(SECRET, 60_000, new Date());
const stolen = await app.inject({
method: 'POST',
url: '/admin/sets',
cookies: cookies(),
headers: { 'x-csrf': csrfToken(otherSession, SECRET) },
payload: { section: 'bar', label: 'Bar Menu' },
});
assert.equal(stolen.statusCode, 403);
});
it('rejects a cross-site mutation even with a session', async () => {
const response = await app.inject({
method: 'POST',
url: '/admin/sets',
cookies: cookies(),
headers: { 'x-csrf': csrf, 'sec-fetch-site': 'cross-site' },
payload: { section: 'bar', label: 'Bar Menu' },
});
assert.equal(response.statusCode, 403);
});
});
describe('the dashboard', () => {
it('lists Sections, their Current set and their Drafts', async () => {
const { id } = await newSet('bar', 'Bar Menu');
await upload(id, 'one.jpg');
await app.inject({
method: 'POST',
url: `/admin/sets/${id}/publish`,
cookies: cookies(),
headers: { 'x-csrf': csrf },
});
await newSet('bar', 'Bar Menu, next');
const response = await app.inject({ method: 'GET', url: '/admin', cookies: cookies() });
assert.equal(response.statusCode, 200);
assert.match(response.body, /Bar Menu/);
assert.match(response.body, /Bar Menu, next/);
assert.match(response.body, /pill--current/);
assert.match(response.body, /pill--draft/);
assert.match(response.body, /No menu published yet/);
assert.match(response.body, /Food Menu/);
});
it('carries a notice through a redirect', async () => {
const response = await app.inject({
method: 'GET',
url: '/admin?notice=Saved.',
cookies: cookies(),
});
assert.match(response.body, /Saved\./);
});
});
describe('the editor', () => {
it('creates a Draft and shows the upload controls', async () => {
const { response, id } = await newSet('bar', 'Bar Menu', '2026-06-26');
assert.equal(response.statusCode, 303);
assert.equal(response.headers['location'], `/admin/sets/${id}`);
const editor = await app.inject({ method: 'GET', url: `/admin/sets/${id}`, cookies: cookies() });
assert.equal(editor.statusCode, 200);
assert.match(editor.body, /data-drop/);
assert.match(editor.body, /data-pages-admin/);
assert.match(editor.body, /enctype="multipart\/form-data"/);
assert.match(editor.body, new RegExp(`data-csrf="${csrf}"`));
assert.match(editor.body, /No pages yet/);
assert.match(editor.body, /value="2026-06-26"/);
});
it('refuses a Section it does not know', async () => {
const response = await app.inject({
method: 'POST',
url: '/admin/sets',
cookies: cookies(),
headers: { 'x-csrf': csrf },
payload: { section: 'pizza', label: 'Pizza' },
});
assert.equal(response.statusCode, 400);
});
it('updates the label and valid-until date', async () => {
const { id } = await newSet();
const response = await app.inject({
method: 'POST',
url: `/admin/sets/${id}/update`,
cookies: cookies(),
headers: { 'x-csrf': csrf, referer: `/admin/sets/${id}` },
payload: { label: 'Bar Menu, summer', validUntil: '2026-09-30' },
});
assert.equal(response.statusCode, 303);
const set = await app.menu.store.get(id);
assert.equal(set.manifest.label, 'Bar Menu, summer');
assert.equal(set.manifest.validUntil, '2026-09-30');
});
});
describe('uploading Pages', () => {
it('accepts a multipart file and reports the count', async () => {
const { id } = await newSet();
const response = await upload(id, 'IMG_0001.JPG');
assert.equal(response.statusCode, 200);
assert.deepEqual(response.json(), { added: 1, failed: [] });
const editor = await app.inject({ method: 'GET', url: `/admin/sets/${id}`, cookies: cookies() });
assert.match(editor.body, /IMG_0001\.JPG/);
assert.match(editor.body, /\/media\/thumb-/);
});
it('reports a file the pipeline will not accept, by name', async () => {
const { id } = await newSet();
const boundary = '----x';
const payload = Buffer.concat([
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="pages"; filename="notes.pdf"\r\nContent-Type: application/pdf\r\n\r\nnot an image\r\n--${boundary}--\r\n`,
),
]);
// The fake pipeline accepts anything, so this exercises the wiring only:
// a real rejection is covered by the pipeline tests.
const response = await app.inject({
method: 'POST',
url: `/admin/sets/${id}/pages`,
cookies: cookies(),
headers: {
'x-csrf': csrf,
accept: 'application/json',
'content-type': `multipart/form-data; boundary=${boundary}`,
},
payload,
});
assert.equal(response.statusCode, 200);
});
it('refuses to add a Page to a published set', async () => {
const { id } = await newSet();
await upload(id, 'one.jpg');
await app.inject({
method: 'POST',
url: `/admin/sets/${id}/publish`,
cookies: cookies(),
headers: { 'x-csrf': csrf },
});
const response = await upload(id, 'two.jpg');
assert.equal(response.statusCode, 409);
});
it('removes a Page', async () => {
const { id } = await newSet();
await upload(id, 'one.jpg');
const set = await app.menu.store.get(id);
const pageId = set.manifest.pages[0]!.id;
const response = await app.inject({
method: 'POST',
url: `/admin/sets/${id}/pages/${pageId}/delete`,
cookies: cookies(),
headers: { 'x-csrf': csrf, referer: `/admin/sets/${id}` },
});
assert.equal(response.statusCode, 303);
assert.equal((await app.menu.store.get(id)).manifest.pages.length, 0);
});
});
describe('ordering', () => {
async function threePages() {
const { id } = await newSet();
await upload(id, 'one.jpg');
await upload(id, 'two.jpg');
await upload(id, 'three.jpg');
const set = await app.menu.store.get(id);
return { id, ids: set.manifest.pages.map((page) => page.id) };
}
it('moves a Page up with a form post', async () => {
const { id, ids } = await threePages();
const response = await app.inject({
method: 'POST',
url: `/admin/sets/${id}/move`,
cookies: cookies(),
headers: { 'x-csrf': csrf, referer: `/admin/sets/${id}` },
payload: { pageId: ids[1], direction: 'up' },
});
assert.equal(response.statusCode, 303);
assert.deepEqual(
(await app.menu.store.get(id)).manifest.pages.map((page) => page.id),
[ids[1], ids[0], ids[2]],
);
});
it('ignores a move past either end', async () => {
const { id, ids } = await threePages();
await app.inject({
method: 'POST',
url: `/admin/sets/${id}/move`,
cookies: cookies(),
headers: { 'x-csrf': csrf, referer: `/admin/sets/${id}` },
payload: { pageId: ids[0], direction: 'up' },
});
assert.deepEqual(
(await app.menu.store.get(id)).manifest.pages.map((page) => page.id),
ids,
);
});
it('reorders from a JSON list, and rejects a bad one', async () => {
const { id, ids } = await threePages();
const response = await app.inject({
method: 'POST',
url: `/admin/sets/${id}/reorder`,
cookies: cookies(),
headers: { 'x-csrf': csrf, accept: 'application/json' },
payload: { pageIds: [ids[2], ids[0], ids[1]] },
});
assert.equal(response.statusCode, 200);
assert.deepEqual(
(await app.menu.store.get(id)).manifest.pages.map((page) => page.id),
[ids[2], ids[0], ids[1]],
);
const bad = await app.inject({
method: 'POST',
url: `/admin/sets/${id}/reorder`,
cookies: cookies(),
headers: { 'x-csrf': csrf, accept: 'application/json' },
payload: { pageIds: [ids[0]] },
});
assert.equal(bad.statusCode, 400);
});
});
describe('publishing, restoring and deleting', () => {
it('publishes a Draft and sends the admin to the live menu', async () => {
const { id } = await newSet('bar', 'Bar Menu', '2026-06-26');
await upload(id, 'one.jpg');
const response = await app.inject({
method: 'POST',
url: `/admin/sets/${id}/publish`,
cookies: cookies(),
headers: { 'x-csrf': csrf },
});
assert.equal(response.statusCode, 303);
assert.equal(response.headers['location'], '/bar/');
assert.equal((await app.menu.store.get(id)).slug, '2026-06-26');
});
it('refuses to publish an empty set', async () => {
const { id } = await newSet();
const response = await app.inject({
method: 'POST',
url: `/admin/sets/${id}/publish`,
cookies: cookies(),
headers: { 'x-csrf': csrf },
});
assert.equal(response.statusCode, 409);
});
it('makes an archived set Current again', async () => {
const first = await newSet('bar', 'Bar Menu', '2026-06-26');
await upload(first.id, 'one.jpg');
await app.inject({
method: 'POST',
url: `/admin/sets/${first.id}/publish`,
cookies: cookies(),
headers: { 'x-csrf': csrf },
});
CLOCK.advance(60_000);
const second = await newSet('bar', 'Bar Menu, next');
await upload(second.id, 'two.jpg');
await app.inject({
method: 'POST',
url: `/admin/sets/${second.id}/publish`,
cookies: cookies(),
headers: { 'x-csrf': csrf },
});
CLOCK.advance(60_000);
const response = await app.inject({
method: 'POST',
url: `/admin/sets/${first.id}/restore`,
cookies: cookies(),
headers: { 'x-csrf': csrf },
});
assert.equal(response.statusCode, 303);
const current = (await app.menu.store.currentBySection()).get('bar');
assert.equal(current?.manifest.id, first.id);
});
it('deletes a Draft and refuses to delete the Current set without confirmation', async () => {
const { id } = await newSet();
await upload(id, 'one.jpg');
await app.inject({
method: 'POST',
url: `/admin/sets/${id}/publish`,
cookies: cookies(),
headers: { 'x-csrf': csrf },
});
const unconfirmed = await app.inject({
method: 'POST',
url: `/admin/sets/${id}/delete`,
cookies: cookies(),
headers: { 'x-csrf': csrf },
payload: {},
});
assert.equal(unconfirmed.statusCode, 409);
const confirmed = await app.inject({
method: 'POST',
url: `/admin/sets/${id}/delete`,
cookies: cookies(),
headers: { 'x-csrf': csrf },
payload: { confirm: 'yes' },
});
assert.equal(confirmed.statusCode, 303);
assert.deepEqual(await app.menu.store.list(), []);
});
});
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');
await upload(id, 'one.jpg');
const set = await app.menu.store.get(id);
const page = set.manifest.pages[0]!;
const preview = await app.inject({
method: 'GET',
url: `/admin/sets/${id}/preview`,
cookies: cookies(),
});
assert.equal(preview.statusCode, 200);
assert.match(preview.body, /Preview of a Draft/);
assert.match(preview.body, /alt="Bar, page 1 of 1"/);
assert.match(preview.body, /name="robots" content="noindex"/);
assert.doesNotMatch(preview.body, /Older menus/);
const media = `/media/${page.id}.${page.hash}.webp`;
assert.equal((await app.inject({ method: 'GET', url: media })).statusCode, 404);
assert.equal(
(await app.inject({ method: 'GET', url: media, cookies: cookies() })).statusCode,
200,
);
});
it('needs a session to preview', async () => {
const { id } = await newSet();
const response = await app.inject({ method: 'GET', url: `/admin/sets/${id}/preview` });
assert.equal(response.statusCode, 303);
});
});