Files
menu/src/storage/identity.ts
T
tanshu 32eeed144f 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.
2026-09-22 23:11:40 +05:30

197 lines
6.3 KiB
TypeScript

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