/** * 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; } }