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, /