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:
1 parent
b6f05f6828
commit
32eeed144f
12 files changed
+646
-18
No files matched your search
@@ -6,6 +6,10 @@ Publishing the customer-facing menus of several brewery-restaurant Sites. A Site
|
|||||||
|
|
||||||
### Structure
|
### 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**:
|
**Site**:
|
||||||
One restaurant's customer-facing menu deployment, with its own brand, Sections and content. Each Site runs its own instance of the app.
|
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
|
_Avoid_: brand, tenant, deployment, instance
|
||||||
|
|||||||
@@ -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>.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.
|
||||||
+8
-2
@@ -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),
|
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.
|
and a category can only be removed once it holds no menus.
|
||||||
|
|
||||||
**Change brand or theme.** Edit `config/<site>.site.json`, rebuild and push
|
**Change the restaurant's name or tagline, the icon, or the published dates.** In
|
||||||
(`./deploy.sh <tag>`), then run the playbook with the new tag.
|
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>.site.json`, so they need a
|
||||||
|
rebuild and push (`./deploy.sh <tag>`) and the playbook with the new tag.
|
||||||
|
|
||||||
**Fix a missing image.** If a Page's thumbnail or viewing image is lost while its
|
**Fix a missing image.** If a Page's thumbnail or viewing image is lost while its
|
||||||
Original survives, the app can rebuild them:
|
Original survives, the app can rebuild them:
|
||||||
|
|||||||
@@ -436,3 +436,36 @@ body {
|
|||||||
.fold--categories .muted {
|
.fold--categories .muted {
|
||||||
margin: 0.5rem 0 0;
|
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;
|
||||||
|
}
|
||||||
@@ -30,12 +30,18 @@ export interface LimitsConfig {
|
|||||||
maxPagesPerSet: number;
|
maxPagesPerSet: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DisplayConfig {
|
||||||
|
/** Whether a menu page says when the Menu Set was published. */
|
||||||
|
showDates: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SiteConfig {
|
export interface SiteConfig {
|
||||||
site: string;
|
site: string;
|
||||||
brand: BrandConfig;
|
brand: BrandConfig;
|
||||||
theme: ThemeConfig;
|
theme: ThemeConfig;
|
||||||
sections: SectionConfig[];
|
sections: SectionConfig[];
|
||||||
limits: LimitsConfig;
|
limits: LimitsConfig;
|
||||||
|
display: DisplayConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ConfigError extends Error {
|
export class ConfigError extends Error {
|
||||||
@@ -175,6 +181,7 @@ export function parseSiteConfig(raw: unknown): SiteConfig {
|
|||||||
brand,
|
brand,
|
||||||
theme: parseTheme(root['theme']),
|
theme: parseTheme(root['theme']),
|
||||||
sections: parseSections(root['sections']),
|
sections: parseSections(root['sections']),
|
||||||
|
display: { showDates: true },
|
||||||
limits: {
|
limits: {
|
||||||
maxUploadBytes: optionalPositiveInt(
|
maxUploadBytes: optionalPositiveInt(
|
||||||
limitsRaw['maxUploadBytes'],
|
limitsRaw['maxUploadBytes'],
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { html, Html, join } from './html.ts';
|
|||||||
import { renderDocument } from './layout.ts';
|
import { renderDocument } from './layout.ts';
|
||||||
import { pageCountLabel, setDateLabel } from './pages.ts';
|
import { pageCountLabel, setDateLabel } from './pages.ts';
|
||||||
import type { SectionConfig, SiteConfig } from '../config.ts';
|
import type { SectionConfig, SiteConfig } from '../config.ts';
|
||||||
|
import { ICON_URL, type SiteIdentity } from '../storage/identity.ts';
|
||||||
import { variantFileNames } from '../storage/manifest.ts';
|
import { variantFileNames } from '../storage/manifest.ts';
|
||||||
import type { StoredSet } from '../storage/sets.ts';
|
import type { StoredSet } from '../storage/sets.ts';
|
||||||
|
|
||||||
@@ -90,6 +91,7 @@ export interface DashboardInput {
|
|||||||
config: SiteConfig;
|
config: SiteConfig;
|
||||||
/** Every Section, in order: the Site's own categories, not configuration. */
|
/** Every Section, in order: the Site's own categories, not configuration. */
|
||||||
sections: readonly SectionConfig[];
|
sections: readonly SectionConfig[];
|
||||||
|
identity: SiteIdentity;
|
||||||
sets: readonly StoredSet[];
|
sets: readonly StoredSet[];
|
||||||
current: ReadonlyMap<string, StoredSet>;
|
current: ReadonlyMap<string, StoredSet>;
|
||||||
csrf: string;
|
csrf: string;
|
||||||
@@ -97,6 +99,44 @@ export interface DashboardInput {
|
|||||||
error: string | null;
|
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 {
|
function renderCategories(input: DashboardInput): Html {
|
||||||
const rows = input.sections.map(
|
const rows = input.sections.map(
|
||||||
(section, index) => html`<li class="catrow">
|
(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.notice === null ? '' : html`<p class="notice">${input.notice}</p>`}
|
||||||
${input.error === null ? '' : html`<p class="notice notice--error">${input.error}</p>`}
|
${input.error === null ? '' : html`<p class="notice notice--error">${input.error}</p>`}
|
||||||
${blocks}
|
${blocks}
|
||||||
|
${renderIdentity(input)}
|
||||||
${renderCategories(input)}
|
${renderCategories(input)}
|
||||||
</main>`,
|
</main>`,
|
||||||
});
|
});
|
||||||
|
|||||||
+3
-1
@@ -232,7 +232,9 @@ export function renderSectionPage(input: SectionInput): Html {
|
|||||||
: ''}
|
: ''}
|
||||||
<h2 class="section-heading">
|
<h2 class="section-heading">
|
||||||
<span class="section-heading__label">${set.manifest.label}</span>
|
<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>
|
</h2>
|
||||||
${input.current || input.preview
|
${input.current || input.preview
|
||||||
? ''
|
? ''
|
||||||
|
|||||||
+74
-7
@@ -22,6 +22,7 @@ import { UnsupportedImageError } from '../images/pipeline.ts';
|
|||||||
import type { SetStore, StoredSet } from '../storage/sets.ts';
|
import type { SetStore, StoredSet } from '../storage/sets.ts';
|
||||||
import { StoreError } from '../storage/sets.ts';
|
import { StoreError } from '../storage/sets.ts';
|
||||||
import { sessionCookieAttributes, SESSION_TTL_MS } from '../server.ts';
|
import { sessionCookieAttributes, SESSION_TTL_MS } from '../server.ts';
|
||||||
|
import type { IdentityStore } from '../storage/identity.ts';
|
||||||
import type { SectionStore } from '../storage/sections.ts';
|
import type { SectionStore } from '../storage/sections.ts';
|
||||||
|
|
||||||
const HTML = 'text/html; charset=utf-8';
|
const HTML = 'text/html; charset=utf-8';
|
||||||
@@ -36,6 +37,7 @@ export function describeBytes(bytes: number): string {
|
|||||||
export interface AdminRouteDeps {
|
export interface AdminRouteDeps {
|
||||||
config: SiteConfig;
|
config: SiteConfig;
|
||||||
sections: SectionStore;
|
sections: SectionStore;
|
||||||
|
identity: IdentityStore;
|
||||||
store: SetStore;
|
store: SetStore;
|
||||||
sessionSecret: string;
|
sessionSecret: string;
|
||||||
adminPasswordHash: string | null;
|
adminPasswordHash: string | null;
|
||||||
@@ -65,7 +67,8 @@ export function sameOrigin(request: FastifyRequest): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRouteDeps): Promise<void> {
|
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 throttle = deps.throttle ?? new LoginThrottle();
|
||||||
|
|
||||||
const sessionToken = (request: FastifyRequest): string | undefined =>
|
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) => {
|
app.get('/admin/login', async (request, reply) => {
|
||||||
if (authed(request)) return reply.redirect('/admin', 303);
|
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) => {
|
app.post('/admin/login', async (request, reply) => {
|
||||||
@@ -151,7 +154,7 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
|
|||||||
.code(429)
|
.code(429)
|
||||||
.header('retry-after', String(verdict.retryAfterSeconds))
|
.header('retry-after', String(verdict.retryAfterSeconds))
|
||||||
.type(HTML)
|
.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 password = (request.body as { password?: string } | undefined)?.password ?? '';
|
||||||
const hash = deps.adminPasswordHash;
|
const hash = deps.adminPasswordHash;
|
||||||
@@ -161,7 +164,7 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
|
|||||||
return reply
|
return reply
|
||||||
.code(401)
|
.code(401)
|
||||||
.type(HTML)
|
.type(HTML)
|
||||||
.send(renderLogin(config, 'That password was not right.').value);
|
.send(renderLogin(site(), 'That password was not right.').value);
|
||||||
}
|
}
|
||||||
throttle.reset(request.ip);
|
throttle.reset(request.ip);
|
||||||
reply.setCookie(
|
reply.setCookie(
|
||||||
@@ -185,8 +188,9 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
|
|||||||
return sendHtml(
|
return sendHtml(
|
||||||
reply,
|
reply,
|
||||||
renderDashboard({
|
renderDashboard({
|
||||||
config,
|
config: site(),
|
||||||
sections: sections.list(),
|
sections: sections.list(),
|
||||||
|
identity: deps.identity.view(),
|
||||||
sets: await store.list(),
|
sets: await store.list(),
|
||||||
current: await store.currentBySection(),
|
current: await store.currentBySection(),
|
||||||
csrf: csrfToken(sessionToken(request), deps.sessionSecret),
|
csrf: csrfToken(sessionToken(request), deps.sessionSecret),
|
||||||
@@ -232,7 +236,7 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
|
|||||||
return sendHtml(
|
return sendHtml(
|
||||||
reply,
|
reply,
|
||||||
renderEditor({
|
renderEditor({
|
||||||
config,
|
config: site(),
|
||||||
section,
|
section,
|
||||||
set,
|
set,
|
||||||
csrf: csrfToken(sessionToken(request), deps.sessionSecret),
|
csrf: csrfToken(sessionToken(request), deps.sessionSecret),
|
||||||
@@ -252,7 +256,7 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
|
|||||||
return sendHtml(
|
return sendHtml(
|
||||||
reply,
|
reply,
|
||||||
renderSectionPage({
|
renderSectionPage({
|
||||||
config,
|
config: site(),
|
||||||
section,
|
section,
|
||||||
set,
|
set,
|
||||||
menu: menuLinks(sections.list(), await store.currentBySection()),
|
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.` });
|
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 ----------
|
// ---------- Pages ----------
|
||||||
|
|
||||||
app.post<{ Params: { id: string } }>('/admin/sets/:id/pages', async (request, reply) => {
|
app.post<{ Params: { id: string } }>('/admin/sets/:id/pages', async (request, reply) => {
|
||||||
|
|||||||
+30
-8
@@ -1,8 +1,10 @@
|
|||||||
import { createReadStream } from 'node:fs';
|
import { createReadStream } from 'node:fs';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
import { stat } from 'node:fs/promises';
|
import { stat } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import type { SiteConfig } from '../config.ts';
|
import type { SiteConfig } from '../config.ts';
|
||||||
|
import type { IdentityStore } from '../storage/identity.ts';
|
||||||
import type { SectionStore } from '../storage/sections.ts';
|
import type { SectionStore } from '../storage/sections.ts';
|
||||||
import { SESSION_COOKIE, verifySessionToken } from '../auth.ts';
|
import { SESSION_COOKIE, verifySessionToken } from '../auth.ts';
|
||||||
import { variantFileNames } from '../storage/manifest.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 {
|
export interface PublicRouteDeps {
|
||||||
config: SiteConfig;
|
config: SiteConfig;
|
||||||
|
identity: IdentityStore;
|
||||||
sections: SectionStore;
|
sections: SectionStore;
|
||||||
store: SetStore;
|
store: SetStore;
|
||||||
sessionSecret: string;
|
sessionSecret: string;
|
||||||
@@ -50,7 +53,8 @@ export async function registerPublicRoutes(
|
|||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
deps: PublicRouteDeps,
|
deps: PublicRouteDeps,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { config, sections, store } = deps;
|
const { sections, store } = deps;
|
||||||
|
const site = (): SiteConfig => deps.identity.apply(deps.config);
|
||||||
|
|
||||||
const isAuthed = (request: FastifyRequest): boolean =>
|
const isAuthed = (request: FastifyRequest): boolean =>
|
||||||
verifySessionToken(request.cookies[SESSION_COOKIE], deps.sessionSecret);
|
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('/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) =>
|
app.get('/robots.txt', async (_request, reply) =>
|
||||||
reply.type('text/plain').send('User-agent: *\nDisallow: /admin\n'),
|
reply.type('text/plain').send('User-agent: *\nDisallow: /admin\n'),
|
||||||
);
|
);
|
||||||
|
|
||||||
app.get('/', async (_request, reply) => {
|
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) => {
|
app.get<{ Params: { section: string } }>('/:section/', async (request, reply) => {
|
||||||
const section = sections.bySlug(request.params.section);
|
const section = sections.bySlug(request.params.section);
|
||||||
if (section === undefined) {
|
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);
|
const current = (await store.currentBySection()).get(section.slug);
|
||||||
if (current === undefined) {
|
if (current === undefined) {
|
||||||
return sendHtml(reply, renderNotFound(config, sections.list()).value, 404);
|
return sendHtml(reply, renderNotFound(site(), sections.list()).value, 404);
|
||||||
}
|
}
|
||||||
return sendHtml(
|
return sendHtml(
|
||||||
reply,
|
reply,
|
||||||
renderSectionPage({
|
renderSectionPage({
|
||||||
config,
|
config: site(),
|
||||||
section,
|
section,
|
||||||
set: current,
|
set: current,
|
||||||
menu: await menu(),
|
menu: await menu(),
|
||||||
@@ -95,7 +117,7 @@ export async function registerPublicRoutes(
|
|||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const section = sections.bySlug(request.params.section);
|
const section = sections.bySlug(request.params.section);
|
||||||
if (section === undefined) {
|
if (section === undefined) {
|
||||||
return sendHtml(reply, renderNotFound(config, []).value, 404);
|
return sendHtml(reply, renderNotFound(site(), []).value, 404);
|
||||||
}
|
}
|
||||||
const set = (await store.list()).find(
|
const set = (await store.list()).find(
|
||||||
(candidate) =>
|
(candidate) =>
|
||||||
@@ -104,7 +126,7 @@ export async function registerPublicRoutes(
|
|||||||
candidate.slug === request.params.slug,
|
candidate.slug === request.params.slug,
|
||||||
);
|
);
|
||||||
if (set === undefined) {
|
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 current = (await store.currentBySection()).get(section.slug);
|
||||||
const archives = (await store.archives(section.slug)).filter(
|
const archives = (await store.archives(section.slug)).filter(
|
||||||
@@ -113,7 +135,7 @@ export async function registerPublicRoutes(
|
|||||||
return sendHtml(
|
return sendHtml(
|
||||||
reply,
|
reply,
|
||||||
renderSectionPage({
|
renderSectionPage({
|
||||||
config,
|
config: site(),
|
||||||
section,
|
section,
|
||||||
set,
|
set,
|
||||||
menu: await menu(),
|
menu: await menu(),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { SESSION_COOKIE, sessionTtl, type LoginThrottle } from './auth.ts';
|
|||||||
import { createImagePipeline } from './images/pipeline.ts';
|
import { createImagePipeline } from './images/pipeline.ts';
|
||||||
import { SetStore, type ImagePipeline } from './storage/sets.ts';
|
import { SetStore, type ImagePipeline } from './storage/sets.ts';
|
||||||
import { SectionStore } from './storage/sections.ts';
|
import { SectionStore } from './storage/sections.ts';
|
||||||
|
import { IdentityStore } from './storage/identity.ts';
|
||||||
import { hasFatal, validateSite } from './storage/validate.ts';
|
import { hasFatal, validateSite } from './storage/validate.ts';
|
||||||
import { renderNotFound } from './render/pages.ts';
|
import { renderNotFound } from './render/pages.ts';
|
||||||
import { registerPublicRoutes } from './routes/public.ts';
|
import { registerPublicRoutes } from './routes/public.ts';
|
||||||
@@ -28,6 +29,7 @@ export const SESSION_TTL_MS = 8 * 60 * 60 * 1000;
|
|||||||
export interface MenuContext {
|
export interface MenuContext {
|
||||||
store: SetStore;
|
store: SetStore;
|
||||||
sections: SectionStore;
|
sections: SectionStore;
|
||||||
|
identity: IdentityStore;
|
||||||
sessionSecret: string;
|
sessionSecret: string;
|
||||||
sessionTtlMs: number;
|
sessionTtlMs: number;
|
||||||
adminPasswordHash: string | null;
|
adminPasswordHash: string | null;
|
||||||
@@ -57,6 +59,7 @@ export interface AppOptions {
|
|||||||
throttle?: LoginThrottle;
|
throttle?: LoginThrottle;
|
||||||
/** Created and loaded by main so boot validation sees the same list. */
|
/** Created and loaded by main so boot validation sees the same list. */
|
||||||
sectionsStore?: SectionStore;
|
sectionsStore?: SectionStore;
|
||||||
|
identityStore?: IdentityStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
|
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 });
|
new SectionStore({ dataDir: options.dataDir, seed: options.config.sections });
|
||||||
await sections.load();
|
await sections.load();
|
||||||
|
|
||||||
|
const identity =
|
||||||
|
options.identityStore ??
|
||||||
|
new IdentityStore({ dataDir: options.dataDir, seed: { ...options.config.brand } });
|
||||||
|
await identity.load();
|
||||||
|
|
||||||
const store = new SetStore({
|
const store = new SetStore({
|
||||||
dataDir: options.dataDir,
|
dataDir: options.dataDir,
|
||||||
pipeline: options.pipeline ?? createImagePipeline(),
|
pipeline: options.pipeline ?? createImagePipeline(),
|
||||||
@@ -100,6 +108,7 @@ export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
|
|||||||
await registerPublicRoutes(app, {
|
await registerPublicRoutes(app, {
|
||||||
config: options.config,
|
config: options.config,
|
||||||
sections,
|
sections,
|
||||||
|
identity,
|
||||||
store,
|
store,
|
||||||
sessionSecret: options.sessionSecret,
|
sessionSecret: options.sessionSecret,
|
||||||
});
|
});
|
||||||
@@ -107,6 +116,7 @@ export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
|
|||||||
await registerAdminRoutes(app, {
|
await registerAdminRoutes(app, {
|
||||||
config: options.config,
|
config: options.config,
|
||||||
sections,
|
sections,
|
||||||
|
identity,
|
||||||
store,
|
store,
|
||||||
sessionSecret: options.sessionSecret,
|
sessionSecret: options.sessionSecret,
|
||||||
adminPasswordHash: options.adminPasswordHash,
|
adminPasswordHash: options.adminPasswordHash,
|
||||||
@@ -133,6 +143,7 @@ export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
|
|||||||
app.decorate('menu', {
|
app.decorate('menu', {
|
||||||
store,
|
store,
|
||||||
sections,
|
sections,
|
||||||
|
identity,
|
||||||
sessionSecret: options.sessionSecret,
|
sessionSecret: options.sessionSecret,
|
||||||
sessionTtlMs: SESSION_TTL_MS,
|
sessionTtlMs: SESSION_TTL_MS,
|
||||||
adminPasswordHash: options.adminPasswordHash,
|
adminPasswordHash: options.adminPasswordHash,
|
||||||
@@ -176,6 +187,8 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
const sections = new SectionStore({ dataDir, seed: config.sections });
|
const sections = new SectionStore({ dataDir, seed: config.sections });
|
||||||
await sections.load();
|
await sections.load();
|
||||||
|
const identity = new IdentityStore({ dataDir, seed: { ...config.brand } });
|
||||||
|
await identity.load();
|
||||||
|
|
||||||
const problems = await validateSite({ dataDir, sections });
|
const problems = await validateSite({ dataDir, sections });
|
||||||
for (const problem of problems) {
|
for (const problem of problems) {
|
||||||
@@ -192,6 +205,7 @@ async function main(): Promise<void> {
|
|||||||
const app = await buildApp({
|
const app = await buildApp({
|
||||||
config,
|
config,
|
||||||
sectionsStore: sections,
|
sectionsStore: sections,
|
||||||
|
identityStore: identity,
|
||||||
dataDir,
|
dataDir,
|
||||||
sessionSecret,
|
sessionSecret,
|
||||||
adminPasswordHash,
|
adminPasswordHash,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10"/></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 = `<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)">
|
||||||
|
<script>alert('x')</script>
|
||||||
|
<foreignObject><body xmlns="http://www.w3.org/1999/xhtml">hi</body></foreignObject>
|
||||||
|
<a href="javascript:alert(2)"><rect width="4" height="4" onclick="alert(3)"/></a>
|
||||||
|
<style>rect{fill:red}</style>
|
||||||
|
</svg>`;
|
||||||
|
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, /<style/i);
|
||||||
|
assert.match(clean, /<rect width="4" height="4"\/>/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('IdentityStore', () => {
|
||||||
|
it('seeds from the config the first time and writes the file', async () => {
|
||||||
|
const store = await loaded();
|
||||||
|
assert.deepEqual(store.view(), { name: BRAND.name, tagline: BRAND.tagline, showDates: true, hasIcon: false });
|
||||||
|
const file = JSON.parse(await readFile(path.join(dataDir, 'site.json'), 'utf8'));
|
||||||
|
assert.equal(file.name, BRAND.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes the name, tagline and date switch without touching the config', async () => {
|
||||||
|
const store = await loaded();
|
||||||
|
const updated = await store.update({ name: ' Hops and Grains ', tagline: '', showDates: false });
|
||||||
|
assert.deepEqual(updated, { name: 'Hops and Grains', tagline: null, showDates: false, hasIcon: false });
|
||||||
|
|
||||||
|
const applied = store.apply(config());
|
||||||
|
assert.equal(applied.brand.name, 'Hops and Grains');
|
||||||
|
assert.equal(applied.brand.tagline, null);
|
||||||
|
assert.equal(applied.display.showDates, false);
|
||||||
|
assert.equal(config().brand.name, 'Hops n Grains', 'the config is only the seed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an empty name', async () => {
|
||||||
|
const store = await loaded();
|
||||||
|
await assert.rejects(() => store.update({ name: ' ' }), IdentityError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes an uploaded icon and points the Site at it', async () => {
|
||||||
|
const store = await loaded();
|
||||||
|
await store.putIcon(Buffer.from(SVG));
|
||||||
|
assert.equal(store.view().hasIcon, true);
|
||||||
|
assert.equal(store.apply(config()).brand.logo, ICON_URL);
|
||||||
|
assert.match(await readFile(store.iconPath(), 'utf8'), /<svg/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses anything that is not a usable SVG', async () => {
|
||||||
|
const store = await loaded();
|
||||||
|
await assert.rejects(() => store.putIcon(Buffer.alloc(0)), IdentityError);
|
||||||
|
await assert.rejects(() => store.putIcon(Buffer.from('a PNG, honest')), IdentityError);
|
||||||
|
await assert.rejects(() => store.putIcon(Buffer.from('<svg>')), IdentityError);
|
||||||
|
await assert.rejects(() => store.putIcon(Buffer.alloc(300 * 1024, 32)), IdentityError);
|
||||||
|
assert.equal(store.view().hasIcon, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forgets the icon on request and falls back to the bundled mark', async () => {
|
||||||
|
const store = await loaded();
|
||||||
|
await store.putIcon(Buffer.from(SVG));
|
||||||
|
await store.removeIcon();
|
||||||
|
assert.equal(store.view().hasIcon, false);
|
||||||
|
assert.equal(store.apply(config()).brand.logo, '/assets/favicon.svg');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('identity through the app', () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
let session: string;
|
||||||
|
let csrf: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
app = await buildApp({
|
||||||
|
config: config(),
|
||||||
|
dataDir,
|
||||||
|
sessionSecret: SECRET,
|
||||||
|
adminPasswordHash: null,
|
||||||
|
assetsDir: path.join(import.meta.dirname, '..', 'public'),
|
||||||
|
secureCookies: false,
|
||||||
|
logLevel: 'silent',
|
||||||
|
pipeline: new FakePipeline(),
|
||||||
|
});
|
||||||
|
await app.ready();
|
||||||
|
session = createSessionToken(SECRET, 60 * 60 * 1000, new Date());
|
||||||
|
csrf = csrfToken(session, SECRET);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const cookies = () => ({ [SESSION_COOKIE]: session });
|
||||||
|
|
||||||
|
async function iconBody(token: string, filename: string, data: Buffer) {
|
||||||
|
const boundary = '----iconboundary';
|
||||||
|
const payload = Buffer.concat([
|
||||||
|
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="csrf"\r\n\r\n${token}\r\n`),
|
||||||
|
Buffer.from(
|
||||||
|
`--${boundary}\r\nContent-Disposition: form-data; name="icon"; filename="${filename}"\r\nContent-Type: image/svg+xml\r\n\r\n`,
|
||||||
|
),
|
||||||
|
data,
|
||||||
|
Buffer.from(`\r\n--${boundary}--\r\n`),
|
||||||
|
]);
|
||||||
|
return app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/admin/identity/icon',
|
||||||
|
cookies: cookies(),
|
||||||
|
headers: {
|
||||||
|
'content-type': `multipart/form-data; boundary=${boundary}`,
|
||||||
|
referer: '/admin',
|
||||||
|
},
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renames the restaurant and hides the dates on the customer pages', async () => {
|
||||||
|
await app.menu.identity.update({ name: 'Hops and Grains', showDates: false });
|
||||||
|
|
||||||
|
const landing = await app.inject({ method: 'GET', url: '/' });
|
||||||
|
assert.match(landing.body, /Hops and Grains/);
|
||||||
|
|
||||||
|
const barSection = await app.inject({ method: 'GET', url: '/bar/' });
|
||||||
|
assert.equal(barSection.statusCode, 404, 'no menu published yet in this fixture');
|
||||||
|
|
||||||
|
// With a menu published, the heading loses its date line but keeps its name.
|
||||||
|
const set = await app.menu.store.create('bar', { label: 'Bar Menu' });
|
||||||
|
await app.menu.store.addPage(set.id, { data: Buffer.from('x'), originalName: 'a.jpg' });
|
||||||
|
await app.menu.store.publish(set.id);
|
||||||
|
|
||||||
|
const published = await app.inject({ method: 'GET', url: '/bar/' });
|
||||||
|
assert.match(published.body, /section-heading__label/);
|
||||||
|
assert.doesNotMatch(published.body, /section-heading__date/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the date when the switch is on', async () => {
|
||||||
|
const set = await app.menu.store.create('bar', { label: 'Bar Menu' });
|
||||||
|
await app.menu.store.addPage(set.id, { data: Buffer.from('x'), originalName: 'a.jpg' });
|
||||||
|
await app.menu.store.publish(set.id);
|
||||||
|
|
||||||
|
const published = await app.inject({ method: 'GET', url: '/bar/' });
|
||||||
|
assert.match(published.body, /section-heading__date/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an SVG icon, serves it with a locked-down policy, and drops it again', async () => {
|
||||||
|
assert.equal((await app.inject({ method: 'GET', url: ICON_URL })).statusCode, 404);
|
||||||
|
|
||||||
|
const uploaded = await iconBody(csrf, 'mark.svg', Buffer.from(SVG));
|
||||||
|
assert.equal(uploaded.statusCode, 303);
|
||||||
|
|
||||||
|
const icon = await app.inject({ method: 'GET', url: ICON_URL });
|
||||||
|
assert.equal(icon.statusCode, 200);
|
||||||
|
assert.match(String(icon.headers['content-type']), /image\/svg\+xml/);
|
||||||
|
assert.match(String(icon.headers['content-security-policy']), /default-src 'none'/);
|
||||||
|
assert.match(icon.body, /<svg/);
|
||||||
|
|
||||||
|
const landing = await app.inject({ method: 'GET', url: '/' });
|
||||||
|
assert.match(landing.body, new RegExp(ICON_URL.replace('/', '\\/')));
|
||||||
|
|
||||||
|
const removed = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/admin/identity/icon/delete',
|
||||||
|
cookies: cookies(),
|
||||||
|
headers: { 'x-csrf': csrf, referer: '/admin' },
|
||||||
|
});
|
||||||
|
assert.equal(removed.statusCode, 303);
|
||||||
|
assert.equal((await app.inject({ method: 'GET', url: ICON_URL })).statusCode, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an icon that is not an SVG, and refuses one with no token', async () => {
|
||||||
|
const notSvg = await iconBody(csrf, 'photo.jpg', Buffer.from('just a jpeg'));
|
||||||
|
assert.match(String(notSvg.headers['location']), /error=/);
|
||||||
|
assert.equal((await app.inject({ method: 'GET', url: ICON_URL })).statusCode, 404);
|
||||||
|
|
||||||
|
const noToken = await iconBody('nonsense', 'mark.svg', Buffer.from(SVG));
|
||||||
|
assert.equal(noToken.statusCode, 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('needs a token to change the identity', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/admin/identity',
|
||||||
|
cookies: cookies(),
|
||||||
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||||
|
payload: 'name=Hijacked',
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 403);
|
||||||
|
assert.equal(app.menu.identity.view().name, BRAND.name);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user