From 32eeed144f6c329f3f9eff6f5bdad1439a4fd6a2 Mon Sep 17 00:00:00 2001 From: Amritanshu Date: Tue, 22 Sep 2026 23:11:40 +0530 Subject: [PATCH] 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. --- CONTEXT.md | 4 + docs/adr/0008-site-identity-is-content.md | 5 + docs/operating.md | 10 +- public/admin.css | 33 ++++ src/config.ts | 7 + src/render/admin.ts | 41 ++++ src/render/pages.ts | 4 +- src/routes/admin.ts | 81 +++++++- src/routes/public.ts | 38 +++- src/server.ts | 14 ++ src/storage/identity.ts | 196 ++++++++++++++++++ test/identity.test.ts | 231 ++++++++++++++++++++++ 12 files changed, 646 insertions(+), 18 deletions(-) create mode 100644 docs/adr/0008-site-identity-is-content.md create mode 100644 src/storage/identity.ts create mode 100644 test/identity.test.ts diff --git a/CONTEXT.md b/CONTEXT.md index dba1454..a7ffed5 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -6,6 +6,10 @@ Publishing the customer-facing menus of several brewery-restaurant Sites. A Site ### Structure +**Site identity**: +The restaurant's name, tagline, icon and display switches, as customers see them. Edited in the admin, stored in the Site's data rather than its configuration. +_Avoid_: brand config, theme + **Site**: One restaurant's customer-facing menu deployment, with its own brand, Sections and content. Each Site runs its own instance of the app. _Avoid_: brand, tenant, deployment, instance diff --git a/docs/adr/0008-site-identity-is-content.md b/docs/adr/0008-site-identity-is-content.md new file mode 100644 index 0000000..1cbf2ae --- /dev/null +++ b/docs/adr/0008-site-identity-is-content.md @@ -0,0 +1,5 @@ +# The restaurant's name, tagline and icon are content + +Changing the name of the restaurant used to mean editing `config/.site.json`, rebuilding the image and redeploying. The name, tagline, icon and the switch for showing published dates now live in `data/site.json` and are edited in the admin, extending ADR 0007's split: what the owner changes is content, and the config is only the seed plus the things that need a rebuild (the theme). + +**Consequences**: an uploaded SVG is served from this origin, so it is reduced to shapes on the way in (no script, no event handlers, no remote references) and served with `default-src 'none'`; the icon's URL is fixed at `/brand/icon.svg` so replacing it does not invalidate anything cached elsewhere. Because the app holds the identity in memory, a hand-edit of `data/site.json` on the server is not picked up until the container restarts — the admin is the supported way in. diff --git a/docs/operating.md b/docs/operating.md index 178266d..6071668 100644 --- a/docs/operating.md +++ b/docs/operating.md @@ -39,8 +39,14 @@ the order customers see. Two rules: a category's web address is fixed when it is created (rename the name as often as you like — a printed QR code keeps working), and a category can only be removed once it holds no menus. -**Change brand or theme.** Edit `config/.site.json`, rebuild and push -(`./deploy.sh `), then run the playbook with the new tag. +**Change the restaurant's name or tagline, the icon, or the published dates.** In +the admin, open the **Site identity** fold: the name and tagline customers see, +a checkbox for whether a menu says when it was published, and an SVG upload for +the icon (under 256 KB; the upload replaces the bundled mark and "Remove the +uploaded icon" brings it back). None of this changes your web addresses. + +**Change the colours.** Those are still `config/.site.json`, so they need a +rebuild and push (`./deploy.sh `) and the playbook with the new tag. **Fix a missing image.** If a Page's thumbnail or viewing image is lost while its Original survives, the app can rebuild them: diff --git a/public/admin.css b/public/admin.css index 76998a7..938b6f9 100644 --- a/public/admin.css +++ b/public/admin.css @@ -436,3 +436,36 @@ body { .fold--categories .muted { margin: 0.5rem 0 0; } + +/* ---------- Site identity ---------- */ + +.check { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--ink-soft, var(--ink-dim)); + font-size: 0.85rem; +} + +.check input[type='checkbox'] { + width: 1.05rem; + height: 1.05rem; + accent-color: var(--accent); +} + +.identity__icon { + width: 40px; + height: 40px; + border: 1px solid var(--hairline); + border-radius: var(--radius-pill, 999px); + background: var(--rail); + object-fit: contain; +} + +.fold--identity .inline-form { + margin-top: 0.9rem; +} + +.fold--identity .inline-form + form { + margin-top: 0.6rem; +} diff --git a/src/config.ts b/src/config.ts index 8636162..1ed59eb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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'], diff --git a/src/render/admin.ts b/src/render/admin.ts index 09506c2..f00d820 100644 --- a/src/render/admin.ts +++ b/src/render/admin.ts @@ -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; 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`
+ Site identity +

+ 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. +

+
+ + ${labelled('Name', html``)} + ${labelled('Tagline', html``)} + + +
+
+ + + + +
+ ${identity.hasIcon + ? html`
+ + +
` + : ''} +
`; +} + function renderCategories(input: DashboardInput): Html { const rows = input.sections.map( (section, index) => html`
  • @@ -199,6 +239,7 @@ export function renderDashboard(input: DashboardInput): Html { ${input.notice === null ? '' : html`

    ${input.notice}

    `} ${input.error === null ? '' : html`

    ${input.error}

    `} ${blocks} + ${renderIdentity(input)} ${renderCategories(input)} `, }); diff --git a/src/render/pages.ts b/src/render/pages.ts index 99b0960..bddf46e 100644 --- a/src/render/pages.ts +++ b/src/render/pages.ts @@ -232,7 +232,9 @@ export function renderSectionPage(input: SectionInput): Html { : ''}

    ${set.manifest.label} - ${setDateLabel(set.manifest)} + ${config.display.showDates + ? html`${setDateLabel(set.manifest)}` + : ''}

    ${input.current || input.preview ? '' diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 72d37a4..c0567f0 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -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 { - 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) => { diff --git a/src/routes/public.ts b/src/routes/public.ts index 90273aa..b9b0b7e 100644 --- a/src/routes/public.ts +++ b/src/routes/public.ts @@ -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 { - 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(), diff --git a/src/server.ts b/src/server.ts index a8a9c37..750c6ef 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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 { @@ -89,6 +92,11 @@ export async function buildApp(options: AppOptions): Promise { 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 { await registerPublicRoutes(app, { config: options.config, sections, + identity, store, sessionSecret: options.sessionSecret, }); @@ -107,6 +116,7 @@ export async function buildApp(options: AppOptions): Promise { 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 { app.decorate('menu', { store, sections, + identity, sessionSecret: options.sessionSecret, sessionTtlMs: SESSION_TTL_MS, adminPasswordHash: options.adminPasswordHash, @@ -176,6 +187,8 @@ async function main(): Promise { 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 { const app = await buildApp({ config, sectionsStore: sections, + identityStore: identity, dataDir, sessionSecret, adminPasswordHash, diff --git a/src/storage/identity.ts b/src/storage/identity.ts new file mode 100644 index 0000000..ed1b689 --- /dev/null +++ b/src/storage/identity.ts @@ -0,0 +1,196 @@ +/** + * The Site's own name, tagline, icon and display switches. These started in + * `config/.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; + 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(//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 { + 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 { + 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 { + 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 (!/]/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 { + 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 { + await writeJsonAtomic(this.file, next); + this.stored = next; + } +} diff --git a/test/identity.test.ts b/test/identity.test.ts new file mode 100644 index 0000000..b3cc5e0 --- /dev/null +++ b/test/identity.test.ts @@ -0,0 +1,231 @@ +import { strict as assert } from 'node:assert'; +import { readFile, rm } from 'node:fs/promises'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, it } from 'node:test'; +import type { FastifyInstance } from 'fastify'; +import { SESSION_COOKIE, createSessionToken, csrfToken } from '../src/auth.ts'; +import { parseSiteConfig } from '../src/config.ts'; +import { ICON_URL, IdentityError, IdentityStore, sanitiseIcon } from '../src/storage/identity.ts'; +import { buildApp } from '../src/server.ts'; +import { FakePipeline, tempDir } from './support/harness.ts'; + +const BRAND = { name: 'Hops n Grains', tagline: 'Handcrafted Microbrewery', logo: '/assets/favicon.svg' }; +const SECRET = 'identity-test-secret'; +const SVG = ''; + +let dataDir: string; + +beforeEach(async () => { + dataDir = await tempDir('identity'); +}); + +afterEach(async () => { + await rm(dataDir, { recursive: true, force: true }); +}); + +function config() { + return parseSiteConfig({ + site: 'menu.example.test', + brand: BRAND, + sections: [{ slug: 'bar', label: 'Bar', order: 1 }], + }); +} + +async function loaded() { + const store = new IdentityStore({ dataDir, seed: { ...BRAND } }); + await store.load(); + return store; +} + +describe('sanitiseIcon', () => { + it('strips script, event handlers and remote references but keeps the drawing', () => { + const dirty = ` + + hi + + + `; + const clean = sanitiseIcon(dirty); + assert.doesNotMatch(clean, /script/i); + assert.doesNotMatch(clean, /onload|onclick/i); + assert.doesNotMatch(clean, /foreignObject/i); + assert.doesNotMatch(clean, /javascript:/i); + assert.doesNotMatch(clean, /