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:
1 parent
9cb4021229
commit
58ecfa0dc3
14 files changed
+1824
-15
No files matched your search
+22
-2
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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>`,
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user