Let the owner change the name, the icon and the dates

Three things that needed a rebuild and a redeploy, or had no way in at all, now
have a Site identity panel in the admin: the restaurant's name and tagline, an
SVG icon upload, and a switch for whether a menu says when it was published.
They live in data/site.json and data/brand/icon.svg, extending ADR 0007: what
the owner changes is content, and the config is the seed plus the theme, which
still needs a rebuild.

An uploaded SVG is a document that would run with this origin's authority, so it
is reduced to shapes on the way in and served with default-src 'none'. The
archive list keeps its dates even when the switch is off, because those dates
are how one old menu is told from another.

Also fixes the live check's own trap: the app caches identity in memory, so the
tests drive it through the app rather than a second store instance, which would
be a second writer.
This commit is contained in:
tanshu committed 2026-09-22 23:11:40 +05:30
1 parent b6f05f6828
commit 32eeed144f
12 files changed
+646 -18

No files matched your search

+7
View File
@@ -30,12 +30,18 @@ export interface LimitsConfig {
maxPagesPerSet: number;
}
export interface DisplayConfig {
/** Whether a menu page says when the Menu Set was published. */
showDates: boolean;
}
export interface SiteConfig {
site: string;
brand: BrandConfig;
theme: ThemeConfig;
sections: SectionConfig[];
limits: LimitsConfig;
display: DisplayConfig;
}
export class ConfigError extends Error {
@@ -175,6 +181,7 @@ export function parseSiteConfig(raw: unknown): SiteConfig {
brand,
theme: parseTheme(root['theme']),
sections: parseSections(root['sections']),
display: { showDates: true },
limits: {
maxUploadBytes: optionalPositiveInt(
limitsRaw['maxUploadBytes'],
+41
View File
@@ -9,6 +9,7 @@ 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 { ICON_URL, type SiteIdentity } from '../storage/identity.ts';
import { variantFileNames } from '../storage/manifest.ts';
import type { StoredSet } from '../storage/sets.ts';
@@ -90,6 +91,7 @@ export interface DashboardInput {
config: SiteConfig;
/** Every Section, in order: the Site's own categories, not configuration. */
sections: readonly SectionConfig[];
identity: SiteIdentity;
sets: readonly StoredSet[];
current: ReadonlyMap<string, StoredSet>;
csrf: string;
@@ -97,6 +99,44 @@ export interface DashboardInput {
error: string | null;
}
function renderIdentity(input: DashboardInput): Html {
const { identity } = input;
const icon = identity.hasIcon ? ICON_URL : (input.config.brand.logo ?? '/assets/favicon.svg');
return html`<details class="fold fold--identity">
<summary>Site identity</summary>
<p class="muted">
The name and tagline customers see, and whether a menu says when it was published. The
web addresses of your pages do not change with the name.
</p>
<form class="inline-form" method="post" action="/admin/identity">
<input type="hidden" name="csrf" value="${input.csrf}" />
${labelled('Name', html`<input type="text" name="name" value="${identity.name}" required />`)}
${labelled('Tagline', html`<input type="text" name="tagline" value="${identity.tagline ?? ''}" />`)}
<label class="check">
<input type="checkbox" name="showDates" value="yes" ${identity.showDates ? 'checked' : ''} />
Show the published date on menus
</label>
<button class="button" type="submit">Save</button>
</form>
<form class="inline-form" method="post" action="/admin/identity/icon" enctype="multipart/form-data">
<input type="hidden" name="csrf" value="${input.csrf}" />
<img class="identity__icon" src="${icon}" alt="" width="40" height="40" />
<label class="check">
<input type="file" name="icon" accept="image/svg+xml,.svg" required />
Replace the icon (SVG, under 256 KB)
</label>
<button class="button" type="submit">Upload icon</button>
</form>
${identity.hasIcon
? html`<form method="post" action="/admin/identity/icon/delete">
<input type="hidden" name="csrf" value="${input.csrf}" />
<button class="button" type="submit">Remove the uploaded icon</button>
</form>`
: ''}
</details>`;
}
function renderCategories(input: DashboardInput): Html {
const rows = input.sections.map(
(section, index) => html`<li class="catrow">
@@ -199,6 +239,7 @@ export function renderDashboard(input: DashboardInput): Html {
${input.notice === null ? '' : html`<p class="notice">${input.notice}</p>`}
${input.error === null ? '' : html`<p class="notice notice--error">${input.error}</p>`}
${blocks}
${renderIdentity(input)}
${renderCategories(input)}
</main>`,
});
+3 -1
View File
@@ -232,7 +232,9 @@ export function renderSectionPage(input: SectionInput): Html {
: ''}
<h2 class="section-heading">
<span class="section-heading__label">${set.manifest.label}</span>
<span class="section-heading__date">${setDateLabel(set.manifest)}</span>
${config.display.showDates
? html`<span class="section-heading__date">${setDateLabel(set.manifest)}</span>`
: ''}
</h2>
${input.current || input.preview
? ''
+74 -7
View File
@@ -22,6 +22,7 @@ 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';
import type { IdentityStore } from '../storage/identity.ts';
import type { SectionStore } from '../storage/sections.ts';
const HTML = 'text/html; charset=utf-8';
@@ -36,6 +37,7 @@ export function describeBytes(bytes: number): string {
export interface AdminRouteDeps {
config: SiteConfig;
sections: SectionStore;
identity: IdentityStore;
store: SetStore;
sessionSecret: string;
adminPasswordHash: string | null;
@@ -65,7 +67,8 @@ export function sameOrigin(request: FastifyRequest): boolean {
}
export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRouteDeps): Promise<void> {
const { config, sections, store } = deps;
const { sections, store } = deps;
const site = (): SiteConfig => deps.identity.apply(deps.config);
const throttle = deps.throttle ?? new LoginThrottle();
const sessionToken = (request: FastifyRequest): string | undefined =>
@@ -140,7 +143,7 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
app.get('/admin/login', async (request, reply) => {
if (authed(request)) return reply.redirect('/admin', 303);
return sendHtml(reply, renderLogin(config, null).value);
return sendHtml(reply, renderLogin(site(), null).value);
});
app.post('/admin/login', async (request, reply) => {
@@ -151,7 +154,7 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
.code(429)
.header('retry-after', String(verdict.retryAfterSeconds))
.type(HTML)
.send(renderLogin(config, `Too many attempts. Try again in ${verdict.retryAfterSeconds}s.`).value);
.send(renderLogin(site(), `Too many attempts. Try again in ${verdict.retryAfterSeconds}s.`).value);
}
const password = (request.body as { password?: string } | undefined)?.password ?? '';
const hash = deps.adminPasswordHash;
@@ -161,7 +164,7 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
return reply
.code(401)
.type(HTML)
.send(renderLogin(config, 'That password was not right.').value);
.send(renderLogin(site(), 'That password was not right.').value);
}
throttle.reset(request.ip);
reply.setCookie(
@@ -185,8 +188,9 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
return sendHtml(
reply,
renderDashboard({
config,
config: site(),
sections: sections.list(),
identity: deps.identity.view(),
sets: await store.list(),
current: await store.currentBySection(),
csrf: csrfToken(sessionToken(request), deps.sessionSecret),
@@ -232,7 +236,7 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
return sendHtml(
reply,
renderEditor({
config,
config: site(),
section,
set,
csrf: csrfToken(sessionToken(request), deps.sessionSecret),
@@ -252,7 +256,7 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
return sendHtml(
reply,
renderSectionPage({
config,
config: site(),
section,
set,
menu: menuLinks(sections.list(), await store.currentBySection()),
@@ -389,6 +393,69 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
return backWith(request, reply, { notice: `${existing.label} removed.` });
});
// ---------- Site identity ----------
app.post('/admin/identity', async (request, reply) => {
if (!guardAdmin(request, reply)) return reply;
const body = request.body as { name?: string; tagline?: string; showDates?: string };
try {
const updated = await deps.identity.update({
...(body.name === undefined ? {} : { name: body.name }),
tagline: body.tagline ?? null,
showDates: body.showDates === 'yes',
});
return backWith(request, reply, {
notice: `Saved. Customers now see "${updated.name}".`,
});
} catch (error) {
return backWith(request, reply, { error: reason(error) });
}
});
app.post('/admin/identity/icon', async (request, reply) => {
if (!authed(request) || !sameOrigin(request)) return reply.code(403).send('Rejected.');
if (!request.isMultipart()) return reply.code(400).send('Expected a file upload.');
const token = sessionToken(request);
let authorised = verifyCsrf(token, deps.sessionSecret, request.headers['x-csrf']);
let uploaded: Buffer | null = null;
let tooLarge = false;
try {
for await (const part of request.parts()) {
if (part.type === 'field') {
if (part.fieldname === 'csrf') {
authorised = authorised || verifyCsrf(token, deps.sessionSecret, part.value);
}
continue;
}
const data = await part.toBuffer();
if (uploaded === null) uploaded = data;
}
} catch (error) {
if ((error as { code?: string }).code !== 'FST_REQ_FILE_TOO_LARGE') throw error;
tooLarge = true;
}
if (!authorised) return reply.code(403).send('Rejected.');
if (tooLarge) return backWith(request, reply, { error: 'That file is larger than the upload limit.' });
if (uploaded === null) return backWith(request, reply, { error: 'Choose an SVG file first.' });
try {
await deps.identity.putIcon(uploaded);
} catch (error) {
return backWith(request, reply, { error: reason(error) });
}
return backWith(request, reply, {
notice: 'Icon updated. Reload the page if your browser is still showing the old one.',
});
});
app.post('/admin/identity/icon/delete', async (request, reply) => {
if (!guardAdmin(request, reply)) return reply;
await deps.identity.removeIcon();
return backWith(request, reply, { notice: 'Icon removed; the bundled mark is back.' });
});
// ---------- Pages ----------
app.post<{ Params: { id: string } }>('/admin/sets/:id/pages', async (request, reply) => {
+30 -8
View File
@@ -1,8 +1,10 @@
import { createReadStream } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { stat } from 'node:fs/promises';
import path from 'node:path';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import type { SiteConfig } from '../config.ts';
import type { IdentityStore } from '../storage/identity.ts';
import type { SectionStore } from '../storage/sections.ts';
import { SESSION_COOKIE, verifySessionToken } from '../auth.ts';
import { variantFileNames } from '../storage/manifest.ts';
@@ -19,6 +21,7 @@ const MEDIA_NAME = /^(thumb-)?([0-9a-f-]{36})\.([0-9a-f]{8,64})\.(webp|jpg)$/;
export interface PublicRouteDeps {
config: SiteConfig;
identity: IdentityStore;
sections: SectionStore;
store: SetStore;
sessionSecret: string;
@@ -50,7 +53,8 @@ export async function registerPublicRoutes(
app: FastifyInstance,
deps: PublicRouteDeps,
): Promise<void> {
const { config, sections, store } = deps;
const { sections, store } = deps;
const site = (): SiteConfig => deps.identity.apply(deps.config);
const isAuthed = (request: FastifyRequest): boolean =>
verifySessionToken(request.cookies[SESSION_COOKIE], deps.sessionSecret);
@@ -59,27 +63,45 @@ export async function registerPublicRoutes(
app.get('/healthz', async (_request, reply) => reply.type('text/plain').send('ok'));
app.get('/brand/icon.svg', async (_request, reply) => {
if (!deps.identity.view().hasIcon) return reply.code(404).send();
let icon: Buffer;
try {
icon = await readFile(deps.identity.iconPath());
} catch {
return reply.code(404).send();
}
// An SVG can carry script; this one is sanitised on the way in and served
// with everything but its own drawing turned off.
return reply
.header('content-type', 'image/svg+xml')
.header('content-security-policy', "default-src 'none'; style-src 'unsafe-inline'")
.header('x-content-type-options', 'nosniff')
.header('cache-control', 'no-cache')
.send(icon);
});
app.get('/robots.txt', async (_request, reply) =>
reply.type('text/plain').send('User-agent: *\nDisallow: /admin\n'),
);
app.get('/', async (_request, reply) => {
return sendHtml(reply, renderLanding({ config, live: await menu() }).value);
return sendHtml(reply, renderLanding({ config: site(), live: await menu() }).value);
});
app.get<{ Params: { section: string } }>('/:section/', async (request, reply) => {
const section = sections.bySlug(request.params.section);
if (section === undefined) {
return sendHtml(reply, renderNotFound(config, []).value, 404);
return sendHtml(reply, renderNotFound(site(), []).value, 404);
}
const current = (await store.currentBySection()).get(section.slug);
if (current === undefined) {
return sendHtml(reply, renderNotFound(config, sections.list()).value, 404);
return sendHtml(reply, renderNotFound(site(), sections.list()).value, 404);
}
return sendHtml(
reply,
renderSectionPage({
config,
config: site(),
section,
set: current,
menu: await menu(),
@@ -95,7 +117,7 @@ export async function registerPublicRoutes(
async (request, reply) => {
const section = sections.bySlug(request.params.section);
if (section === undefined) {
return sendHtml(reply, renderNotFound(config, []).value, 404);
return sendHtml(reply, renderNotFound(site(), []).value, 404);
}
const set = (await store.list()).find(
(candidate) =>
@@ -104,7 +126,7 @@ export async function registerPublicRoutes(
candidate.slug === request.params.slug,
);
if (set === undefined) {
return sendHtml(reply, renderNotFound(config, sections.list()).value, 404);
return sendHtml(reply, renderNotFound(site(), sections.list()).value, 404);
}
const current = (await store.currentBySection()).get(section.slug);
const archives = (await store.archives(section.slug)).filter(
@@ -113,7 +135,7 @@ export async function registerPublicRoutes(
return sendHtml(
reply,
renderSectionPage({
config,
config: site(),
section,
set,
menu: await menu(),
+14
View File
@@ -16,6 +16,7 @@ import { SESSION_COOKIE, sessionTtl, type LoginThrottle } from './auth.ts';
import { createImagePipeline } from './images/pipeline.ts';
import { SetStore, type ImagePipeline } from './storage/sets.ts';
import { SectionStore } from './storage/sections.ts';
import { IdentityStore } from './storage/identity.ts';
import { hasFatal, validateSite } from './storage/validate.ts';
import { renderNotFound } from './render/pages.ts';
import { registerPublicRoutes } from './routes/public.ts';
@@ -28,6 +29,7 @@ export const SESSION_TTL_MS = 8 * 60 * 60 * 1000;
export interface MenuContext {
store: SetStore;
sections: SectionStore;
identity: IdentityStore;
sessionSecret: string;
sessionTtlMs: number;
adminPasswordHash: string | null;
@@ -57,6 +59,7 @@ export interface AppOptions {
throttle?: LoginThrottle;
/** Created and loaded by main so boot validation sees the same list. */
sectionsStore?: SectionStore;
identityStore?: IdentityStore;
}
export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
@@ -89,6 +92,11 @@ export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
new SectionStore({ dataDir: options.dataDir, seed: options.config.sections });
await sections.load();
const identity =
options.identityStore ??
new IdentityStore({ dataDir: options.dataDir, seed: { ...options.config.brand } });
await identity.load();
const store = new SetStore({
dataDir: options.dataDir,
pipeline: options.pipeline ?? createImagePipeline(),
@@ -100,6 +108,7 @@ export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
await registerPublicRoutes(app, {
config: options.config,
sections,
identity,
store,
sessionSecret: options.sessionSecret,
});
@@ -107,6 +116,7 @@ export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
await registerAdminRoutes(app, {
config: options.config,
sections,
identity,
store,
sessionSecret: options.sessionSecret,
adminPasswordHash: options.adminPasswordHash,
@@ -133,6 +143,7 @@ export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
app.decorate('menu', {
store,
sections,
identity,
sessionSecret: options.sessionSecret,
sessionTtlMs: SESSION_TTL_MS,
adminPasswordHash: options.adminPasswordHash,
@@ -176,6 +187,8 @@ async function main(): Promise<void> {
const sections = new SectionStore({ dataDir, seed: config.sections });
await sections.load();
const identity = new IdentityStore({ dataDir, seed: { ...config.brand } });
await identity.load();
const problems = await validateSite({ dataDir, sections });
for (const problem of problems) {
@@ -192,6 +205,7 @@ async function main(): Promise<void> {
const app = await buildApp({
config,
sectionsStore: sections,
identityStore: identity,
dataDir,
sessionSecret,
adminPasswordHash,
+196
View File
@@ -0,0 +1,196 @@
/**
* The Site's own name, tagline, icon and display switches. These started in
* `config/<site>.site.json`, which meant a rebuild and a redeploy to change the
* restaurant's name; they are content now, like the categories, and the config
* is only the seed for a Site that has never been opened.
*
* The uploaded icon is a fixed path so its URL never changes when it is
* replaced: the data directory owns `brand/icon.svg` and this file records the
* switches around it.
*/
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { SiteConfig } from '../config.ts';
import { writeJsonAtomic } from './manifest.ts';
export const ICON_URL = '/brand/icon.svg';
const MAX_ICON_BYTES = 256 * 1024;
export class IdentityError extends Error {
readonly status: number;
constructor(message: string, status = 400) {
super(message);
this.name = 'IdentityError';
this.status = status;
}
}
export interface SiteIdentity {
name: string;
tagline: string | null;
/** Whether the customer-facing pages say when a menu was published. */
showDates: boolean;
/** True when an uploaded icon is in place. */
hasIcon: boolean;
}
interface StoredIdentity {
name: string;
tagline: string | null;
showDates: boolean;
}
function cleanText(value: unknown, where: string): string {
if (typeof value !== 'string' || value.trim() === '') {
throw new IdentityError(`${where} cannot be empty`);
}
return value.trim().replace(/\s+/g, ' ');
}
function parse(raw: unknown): StoredIdentity {
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
throw new IdentityError('site.json must be an object');
}
const candidate = raw as Record<string, unknown>;
return {
name: cleanText(candidate['name'], 'name'),
tagline:
typeof candidate['tagline'] === 'string' && candidate['tagline'].trim() !== ''
? candidate['tagline'].trim()
: null,
showDates: candidate['showDates'] !== false,
};
}
/**
* An SVG is a document, not a picture: it can carry script and event handlers
* that would run with this origin's authority. Only an authenticated owner can
* upload one, but a stolen or careless icon should still not be a way to run
* code in a customer's browser, so the file is reduced to shapes and the route
* that serves it forbids everything else.
*/
export function sanitiseIcon(source: string): string {
const withoutComments = source.replace(/<!--[\s\S]*?-->/g, '');
const withoutForbidden = withoutComments
.replace(/<\s*(script|foreignObject|iframe|object|embed|style)\b[\s\S]*?<\s*\/\s*\1\s*>/gi, '')
.replace(/<\s*(script|foreignObject|iframe|object|embed|style)\b[^>]*\/?>/gi, '')
.replace(/\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
.replace(/(href|xlink:href)\s*=\s*("\s*javascript:[^"]*"|'\s*javascript:[^']*')/gi, '');
return withoutForbidden.trim();
}
export interface IdentityStoreOptions {
dataDir: string;
seed: SiteConfig['brand'] & { showDates?: boolean };
}
export class IdentityStore {
readonly file: string;
readonly iconFile: string;
private readonly seed: StoredIdentity;
private stored: StoredIdentity | null = null;
private iconPresent = false;
constructor(options: IdentityStoreOptions) {
this.file = path.join(options.dataDir, 'site.json');
this.iconFile = path.join(options.dataDir, 'brand', 'icon.svg');
this.seed = {
name: options.seed.name,
tagline: options.seed.tagline,
showDates: options.seed.showDates ?? true,
};
}
async load(): Promise<void> {
if (this.stored !== null) return;
try {
this.stored = parse(JSON.parse(await readFile(this.file, 'utf8')));
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw new IdentityError(
`${this.file} could not be read: ${error instanceof Error ? error.message : String(error)}`,
);
}
this.stored = { ...this.seed };
await this.save(this.stored);
}
try {
await readFile(this.iconFile);
this.iconPresent = true;
} catch {
this.iconPresent = false;
}
}
view(): SiteIdentity {
const stored = this.require();
return { ...stored, hasIcon: this.iconPresent };
}
/** The Site as the renderers want it: seeded config, overlaid with identity. */
apply(config: SiteConfig): SiteConfig {
const identity = this.view();
return {
...config,
brand: {
name: identity.name,
tagline: identity.tagline,
logo: identity.hasIcon ? ICON_URL : config.brand.logo,
},
display: { showDates: identity.showDates },
};
}
async update(patch: { name?: string; tagline?: string | null; showDates?: boolean }): Promise<SiteIdentity> {
const current = this.require();
const next: StoredIdentity = {
name: patch.name === undefined ? current.name : cleanText(patch.name, 'name'),
tagline:
patch.tagline === undefined
? current.tagline
: patch.tagline === null || patch.tagline.trim() === ''
? null
: patch.tagline.trim(),
showDates: patch.showDates === undefined ? current.showDates : patch.showDates,
};
await this.save(next);
return this.view();
}
async putIcon(upload: Buffer): Promise<void> {
if (upload.byteLength === 0) throw new IdentityError('that file is empty');
if (upload.byteLength > MAX_ICON_BYTES) {
throw new IdentityError(`an icon has to be under ${Math.round(MAX_ICON_BYTES / 1024)} KB`);
}
const text = upload.toString('utf8');
if (!/<svg[\s>]/i.test(text)) throw new IdentityError('that file is not an SVG');
if (!/<\/svg>/i.test(text)) throw new IdentityError('that SVG looks truncated');
await mkdir(path.dirname(this.iconFile), { recursive: true });
await writeFile(this.iconFile, sanitiseIcon(text), { mode: 0o644 });
this.iconPresent = true;
}
async removeIcon(): Promise<void> {
await rm(this.iconFile, { force: true });
this.iconPresent = false;
}
iconPath(): string {
return this.iconFile;
}
private require(): StoredIdentity {
if (this.stored === null) throw new Error('IdentityStore.load() has not been awaited');
return this.stored;
}
private async save(next: StoredIdentity): Promise<void> {
await writeJsonAtomic(this.file, next);
this.stored = next;
}
}