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

+22 -2
View File
@@ -94,8 +94,7 @@ export function verifySessionToken(token: string | undefined, secret: string, no
return Number.isSafeInteger(expiry) && expiry > now.getTime();
}
export interface ThrottleVerdict {
allowed: boolean;
export interface ThrottleVerdict { allowed: boolean;
retryAfterSeconds: number;
}
@@ -147,3 +146,24 @@ export class LoginThrottle {
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;
description: string | null;
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 {
@@ -38,6 +40,7 @@ export function renderDocument(input: DocumentInput): Html {
crossorigin
/>
<link rel="stylesheet" href="/assets/styles.css" />
${input.styles.map((href) => html`<link rel="stylesheet" href="${href}" />`)}
<style>
:root {
--steel: ${theme.steel};
@@ -50,7 +53,7 @@ export function renderDocument(input: DocumentInput): Html {
</head>
<body class="${input.bodyClass}">
${input.body}
${input.script ? html`<script src="/assets/app.js" defer></script>` : ''}
${input.scripts.map((src) => html`<script src="${src}" defer></script>`)}
</body>
</html>
`;
+19 -5
View File
@@ -153,7 +153,8 @@ export function renderLanding(input: LandingInput): Html {
bodyClass: 'landing-page',
description: config.brand.tagline,
noindex: false,
script: false,
styles: [],
scripts: [],
body: html`<main class="landing">
<header class="masthead">
<h1 class="wordmark">${config.brand.name}</h1>
@@ -174,6 +175,8 @@ export interface SectionInput {
published: ReadonlySet<string>;
current: boolean;
archives: readonly StoredSet[];
/** Draft preview for the admin: adds a banner and skips the archive fold. */
preview: boolean;
}
export function renderSectionPage(input: SectionInput): Html {
@@ -186,14 +189,23 @@ export function renderSectionPage(input: SectionInput): Html {
${renderRail(config, section.slug, input.published)}
<div class="pour" aria-hidden="true"><span class="pour__fill" data-pour></span></div>
</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">
<span class="section-heading__label">${set.manifest.label}</span>
<span class="section-heading__date">${setDateLabel(set.manifest)}</span>
</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)}
<footer class="foot">
${renderPageIndex(total)} ${renderOlderMenus(section, input.archives, currentHref)}
${renderPageIndex(total)}
${input.preview ? '' : renderOlderMenus(section, input.archives, currentHref)}
</footer>
<button class="counter" type="button" data-counter hidden>
<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',
description: config.brand.tagline,
noindex: !input.current,
script: true,
styles: [],
scripts: ['/assets/app.js'],
body,
});
}
@@ -228,7 +241,8 @@ export function renderNotFound(config: SiteConfig, live: readonly SectionConfig[
bodyClass: 'landing-page',
description: null,
noindex: true,
script: false,
styles: [],
scripts: [],
body: html`<main class="landing">
<header class="masthead">
<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,
published: await publishedSections(),
current: true,
preview: false,
archives: await store.archives(section.slug),
}).value,
);
@@ -115,6 +116,7 @@ export async function registerPublicRoutes(
set,
published: await publishedSections(),
current: current?.id === set.id,
preview: false,
archives,
}).value,
);
+22 -1
View File
@@ -8,14 +8,17 @@ import { readFile } from 'node:fs/promises';
import path from 'node:path';
import Fastify, { type FastifyError, type FastifyInstance } from 'fastify';
import cookie from '@fastify/cookie';
import formbody from '@fastify/formbody';
import multipart from '@fastify/multipart';
import fastifyStatic from '@fastify/static';
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 { SetStore, type ImagePipeline } from './storage/sets.ts';
import { hasFatal, validateSite } from './storage/validate.ts';
import { renderNotFound } from './render/pages.ts';
import { registerPublicRoutes } from './routes/public.ts';
import { registerAdminRoutes } from './routes/admin.ts';
import { readEnvFile } from './env.ts';
export const SESSION_TTL_MS = 8 * 60 * 60 * 1000;
@@ -48,6 +51,8 @@ export interface AppOptions {
pipeline?: ImagePipeline;
/** Test seam: defaults to the real clock. */
now?: () => Date;
/** Test seam: a shared throttle so rate-limit tests are deterministic. */
throttle?: LoginThrottle;
}
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(formbody);
await app.register(multipart, {
limits: {
fileSize: options.config.limits.maxUploadBytes,
files: options.config.limits.maxPagesPerSet,
},
});
await app.register(fastifyStatic, {
root: options.assetsDir,
prefix: '/assets/',
@@ -80,6 +92,15 @@ export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
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) => {
const current = await store.currentBySection();
const live = options.config.sections.filter((section) => current.has(section.slug));