Add ESLint and Prettier
Prettier owns formatting: .prettierrc.json, format and format:check scripts, and a one-off pass over the repo so the check is green from the start. Embedded language formatting is off deliberately - the renderers build HTML in html tagged templates and letting Prettier reflow those would change the markup customers receive. docs/ is excluded because it is hand-wrapped prose, and shell scripts have no Prettier parser. ESLint covers the JavaScript: the two browser scripts, the build and check scripts, and its own config. no-var is scoped out for the browser scripts on purpose; they are plain ES5-shaped IIFEs and a hoisting difference would only show up in a browser. ESLint cannot lint the TypeScript here: typescript@7 is the native port with no JavaScript compiler API, and typescript-eslint requires a 4.8-6.1 peer, so it refuses to install and forcing it through would leave a parser that cannot read the compiler. tsc --noEmit stays the TypeScript gate; README records the two ways to change that. lint and format:check now run in deploy.sh, and VS Code formats on save with Prettier and surfaces ESLint findings.
This commit is contained in:
1 parent
ce8079355b
commit
e64f65642f
40 files changed
+1469
-172
No files matched your search
+14
-5
@@ -46,7 +46,8 @@ export async function verifyPassword(password: string, stored: string): Promise<
|
||||
const N = Number(parts[1]);
|
||||
const r = Number(parts[2]);
|
||||
const p = Number(parts[3]);
|
||||
if (!Number.isSafeInteger(N) || !Number.isSafeInteger(r) || !Number.isSafeInteger(p)) return false;
|
||||
if (!Number.isSafeInteger(N) || !Number.isSafeInteger(r) || !Number.isSafeInteger(p))
|
||||
return false;
|
||||
let salt: Buffer;
|
||||
let expected: Buffer;
|
||||
try {
|
||||
@@ -79,7 +80,11 @@ export function createSessionToken(secret: string, ttlMs: number, now: Date = ne
|
||||
return `${expiry}.${sign(expiry, secret)}`;
|
||||
}
|
||||
|
||||
export function verifySessionToken(token: string | undefined, secret: string, now: Date = new Date()): boolean {
|
||||
export function verifySessionToken(
|
||||
token: string | undefined,
|
||||
secret: string,
|
||||
now: Date = new Date(),
|
||||
): boolean {
|
||||
if (!token) return false;
|
||||
const separator = token.indexOf('.');
|
||||
if (separator <= 0) return false;
|
||||
@@ -94,7 +99,8 @@ export function verifySessionToken(token: string | undefined, secret: string, no
|
||||
return Number.isSafeInteger(expiry) && expiry > now.getTime();
|
||||
}
|
||||
|
||||
export interface ThrottleVerdict { allowed: boolean;
|
||||
export interface ThrottleVerdict {
|
||||
allowed: boolean;
|
||||
retryAfterSeconds: number;
|
||||
}
|
||||
|
||||
@@ -152,7 +158,9 @@ export class LoginThrottle {
|
||||
* cannot produce it, because it cannot read the httpOnly cookie.
|
||||
*/
|
||||
export function csrfToken(sessionToken: string | undefined, secret: string): string {
|
||||
return createHmac('sha256', secret).update(`csrf:${sessionToken ?? 'anonymous'}`).digest('base64url');
|
||||
return createHmac('sha256', secret)
|
||||
.update(`csrf:${sessionToken ?? 'anonymous'}`)
|
||||
.digest('base64url');
|
||||
}
|
||||
|
||||
export function verifyCsrf(
|
||||
@@ -164,6 +172,7 @@ export function verifyCsrf(
|
||||
const providedBuffer = Buffer.from(provided);
|
||||
const expectedBuffer = Buffer.from(csrfToken(sessionToken, secret));
|
||||
return (
|
||||
providedBuffer.length === expectedBuffer.length && timingSafeEqual(providedBuffer, expectedBuffer)
|
||||
providedBuffer.length === expectedBuffer.length &&
|
||||
timingSafeEqual(providedBuffer, expectedBuffer)
|
||||
);
|
||||
}
|
||||
+7
-4
@@ -134,7 +134,9 @@ function parseSections(value: unknown): SectionConfig[] {
|
||||
const raw = requireObject(entry, `sections[${index}]`);
|
||||
const slug = requireString(raw['slug'], `sections[${index}].slug`);
|
||||
if (!SLUG.test(slug)) {
|
||||
fail(`sections[${index}].slug ${JSON.stringify(slug)} must be lower-case words joined by dashes`);
|
||||
fail(
|
||||
`sections[${index}].slug ${JSON.stringify(slug)} must be lower-case words joined by dashes`,
|
||||
);
|
||||
}
|
||||
if (RESERVED_SLUGS.has(slug)) {
|
||||
fail(`sections[${index}].slug ${JSON.stringify(slug)} is reserved by the router`);
|
||||
@@ -172,9 +174,10 @@ export function parseSiteConfig(raw: unknown): SiteConfig {
|
||||
logo: optionalString(brandRaw['logo'], 'brand.logo'),
|
||||
};
|
||||
|
||||
const limitsRaw = root['limits'] === undefined || root['limits'] === null
|
||||
? {}
|
||||
: requireObject(root['limits'], 'limits');
|
||||
const limitsRaw =
|
||||
root['limits'] === undefined || root['limits'] === null
|
||||
? {}
|
||||
: requireObject(root['limits'], 'limits');
|
||||
|
||||
return {
|
||||
site,
|
||||
|
||||
@@ -63,9 +63,7 @@ export function createImagePipeline(options: ImagePipelineOptions = {}): ImagePi
|
||||
const sourceExt = format === undefined ? undefined : ACCEPTED_FORMATS[format];
|
||||
if (sourceExt === undefined) {
|
||||
const found = format ?? 'unknown';
|
||||
throw new UnsupportedImageError(
|
||||
`${originalName} is ${found}; upload a JPEG, PNG or WebP`,
|
||||
);
|
||||
throw new UnsupportedImageError(`${originalName} is ${found}; upload a JPEG, PNG or WebP`);
|
||||
}
|
||||
|
||||
// .rotate() with no angle auto-orients from EXIF; omitting withMetadata()
|
||||
|
||||
+56
-38
@@ -34,11 +34,7 @@ function shell(input: AdminShellInput): Html {
|
||||
});
|
||||
}
|
||||
|
||||
function labelled(
|
||||
label: string,
|
||||
control: Html,
|
||||
hint: string | null = null,
|
||||
): Html {
|
||||
function labelled(label: string, control: Html, hint: string | null = null): Html {
|
||||
return html`<span class="field">
|
||||
<label>${label}${control}</label>
|
||||
${hint === null ? '' : html`<span class="field__hint">${hint}</span>`}
|
||||
@@ -63,7 +59,11 @@ export function renderLogin(config: SiteConfig, error: string | null): Html {
|
||||
});
|
||||
}
|
||||
|
||||
function setRow(section: SectionConfig, set: StoredSet, kind: 'current' | 'draft' | 'archive'): Html {
|
||||
function setRow(
|
||||
section: SectionConfig,
|
||||
set: StoredSet,
|
||||
kind: 'current' | 'draft' | 'archive',
|
||||
): Html {
|
||||
const pill =
|
||||
kind === 'current'
|
||||
? html`<span class="pill pill--current">Current</span>`
|
||||
@@ -128,12 +128,14 @@ function renderIdentity(input: DashboardInput): Html {
|
||||
</label>
|
||||
<button class="button" type="submit">Upload icon</button>
|
||||
</form>
|
||||
${identity.hasIcon
|
||||
? html`<form method="post" action="/admin/identity/icon/delete">
|
||||
${
|
||||
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>`;
|
||||
}
|
||||
|
||||
@@ -202,14 +204,16 @@ export function renderDashboard(input: DashboardInput): Html {
|
||||
${current === undefined ? html`<li class="setlist__none">No menu published yet.</li>` : setRow(section, current, 'current')}
|
||||
${join(drafts.map((set) => setRow(section, set, 'draft')))}
|
||||
</ul>
|
||||
${archives.length === 0
|
||||
? ''
|
||||
: html`<details class="fold">
|
||||
${
|
||||
archives.length === 0
|
||||
? ''
|
||||
: html`<details class="fold">
|
||||
<summary>${archives.length === 1 ? '1 older menu' : `${archives.length} older menus`}</summary>
|
||||
<ul class="setlist">
|
||||
${join(archives.map((set) => setRow(section, set, 'archive')))}
|
||||
</ul>
|
||||
</details>`}
|
||||
</details>`
|
||||
}
|
||||
<form class="newset" method="post" action="/admin/sets">
|
||||
<input type="hidden" name="csrf" value="${input.csrf}" />
|
||||
<input type="hidden" name="section" value="${section.slug}" />
|
||||
@@ -273,8 +277,9 @@ export function renderEditor(input: EditorInput): Html {
|
||||
<span class="page-admin__name">${page.originalName}</span>
|
||||
<span class="page-admin__dims">${page.width} × ${page.height}</span>
|
||||
</span>
|
||||
${reorderable
|
||||
? html`<span class="page-admin__actions">
|
||||
${
|
||||
reorderable
|
||||
? html`<span class="page-admin__actions">
|
||||
<form method="post" action="/admin/sets/${set.id}/move">
|
||||
<input type="hidden" name="csrf" value="${input.csrf}" />
|
||||
<input type="hidden" name="pageId" value="${page.id}" />
|
||||
@@ -287,14 +292,17 @@ export function renderEditor(input: EditorInput): Html {
|
||||
<input type="hidden" name="direction" value="down" />
|
||||
<button class="button button--icon" type="submit" title="Move down">↓</button>
|
||||
</form>
|
||||
${draft
|
||||
? html`<form method="post" action="/admin/sets/${set.id}/pages/${page.id}/delete">
|
||||
${
|
||||
draft
|
||||
? html`<form method="post" action="/admin/sets/${set.id}/pages/${page.id}/delete">
|
||||
<input type="hidden" name="csrf" value="${input.csrf}" />
|
||||
<button class="button button--icon button--danger" type="submit" title="Remove page">✕</button>
|
||||
</form>`
|
||||
: ''}
|
||||
: ''
|
||||
}
|
||||
</span>`
|
||||
: ''}
|
||||
: ''
|
||||
}
|
||||
</li>`;
|
||||
});
|
||||
|
||||
@@ -312,25 +320,28 @@ export function renderEditor(input: EditorInput): Html {
|
||||
${input.notice === null ? '' : html`<p class="notice">${input.notice}</p>`}
|
||||
${input.error === null ? '' : html`<p class="notice notice--error">${input.error}</p>`}
|
||||
|
||||
${set.state === 'draft'
|
||||
? ''
|
||||
: input.current
|
||||
? html`<p class="notice">
|
||||
${
|
||||
set.state === 'draft'
|
||||
? ''
|
||||
: input.current
|
||||
? html`<p class="notice">
|
||||
This is the menu customers are seeing at
|
||||
<a href="/${section.slug}/">/${section.slug}/</a>. Its Pages are fixed, but you can drag them
|
||||
into a different order and it takes effect straight away. To add, remove or replace a Page,
|
||||
create a new Menu Set.
|
||||
</p>`
|
||||
: html`<p class="notice">
|
||||
: html`<p class="notice">
|
||||
This is an older menu, kept as it was at
|
||||
<a href="/${section.slug}/${set.slug}/">/${section.slug}/${set.slug}/</a>. Archived menus cannot
|
||||
be changed; press "Make current" to put it back in front of customers.
|
||||
</p>`}
|
||||
</p>`
|
||||
}
|
||||
|
||||
<section class="block">
|
||||
<h2 class="block__title">Details</h2>
|
||||
${draft
|
||||
? html`<form class="inline-form" method="post" action="/admin/sets/${set.id}/update">
|
||||
${
|
||||
draft
|
||||
? html`<form class="inline-form" method="post" action="/admin/sets/${set.id}/update">
|
||||
<input type="hidden" name="csrf" value="${input.csrf}" />
|
||||
${labelled('Label', html`<input type="text" name="label" value="${set.manifest.label}" required />`)}
|
||||
${labelled(
|
||||
@@ -339,11 +350,13 @@ export function renderEditor(input: EditorInput): Html {
|
||||
)}
|
||||
<button class="button" type="submit">Save</button>
|
||||
</form>`
|
||||
: html`<p class="muted">${setDateLabel(set.manifest)}</p>`}
|
||||
: html`<p class="muted">${setDateLabel(set.manifest)}</p>`
|
||||
}
|
||||
</section>
|
||||
|
||||
${draft
|
||||
? html`<section class="block">
|
||||
${
|
||||
draft
|
||||
? html`<section class="block">
|
||||
<h2 class="block__title">Pages</h2>
|
||||
<div class="drop" data-drop>
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp,image/*" multiple data-file-input hidden />
|
||||
@@ -363,30 +376,35 @@ export function renderEditor(input: EditorInput): Html {
|
||||
</ul>
|
||||
<p class="drop__status" data-order-status role="status"></p>
|
||||
</section>`
|
||||
: html`<section class="block">
|
||||
: html`<section class="block">
|
||||
<h2 class="block__title">Pages</h2>
|
||||
<ul class="pages-admin" ${reorderable ? html`data-pages-admin` : ''}>${join(pageItems)}</ul>
|
||||
<p class="drop__status" data-order-status role="status"></p>
|
||||
</section>`}
|
||||
</section>`
|
||||
}
|
||||
|
||||
<section class="block">
|
||||
<h2 class="block__title">${draft ? 'Publish' : 'Manage'}</h2>
|
||||
<div class="actions">
|
||||
${draft
|
||||
? html`<form method="post" action="/admin/sets/${set.id}/publish">
|
||||
${
|
||||
draft
|
||||
? html`<form method="post" action="/admin/sets/${set.id}/publish">
|
||||
<input type="hidden" name="csrf" value="${input.csrf}" />
|
||||
<button class="button button--primary" type="submit" ${set.manifest.pages.length === 0 ? 'disabled' : ''}>
|
||||
Publish (${pageCountLabel(set.manifest.pages.length)})
|
||||
</button>
|
||||
</form>
|
||||
<a class="button" href="/admin/sets/${set.id}/preview">Preview</a>`
|
||||
: html`<a class="button" href="/${section.slug}/${set.slug}/">View live</a>`}
|
||||
${set.state === 'published' && !input.current
|
||||
? html`<form method="post" action="/admin/sets/${set.id}/restore">
|
||||
: html`<a class="button" href="/${section.slug}/${set.slug}/">View live</a>`
|
||||
}
|
||||
${
|
||||
set.state === 'published' && !input.current
|
||||
? html`<form method="post" action="/admin/sets/${set.id}/restore">
|
||||
<input type="hidden" name="csrf" value="${input.csrf}" />
|
||||
<button class="button" type="submit">Make current</button>
|
||||
</form>`
|
||||
: ''}
|
||||
: ''
|
||||
}
|
||||
<form method="post" action="/admin/sets/${set.id}/delete" data-confirm="Delete this Menu Set and its Originals? This cannot be undone.">
|
||||
<input type="hidden" name="csrf" value="${input.csrf}" />
|
||||
<input type="hidden" name="confirm" value="yes" />
|
||||
|
||||
@@ -26,9 +26,11 @@ export function renderDocument(input: DocumentInput): Html {
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>${title}</title>
|
||||
${input.description === null
|
||||
? ''
|
||||
: html`<meta name="description" content="${input.description}" />`}
|
||||
${
|
||||
input.description === null
|
||||
? ''
|
||||
: html`<meta name="description" content="${input.description}" />`
|
||||
}
|
||||
${input.noindex ? html`<meta name="robots" content="noindex" />` : ''}
|
||||
<meta name="theme-color" content="${theme.steel}" />
|
||||
<link rel="icon" href="${logo}" />
|
||||
|
||||
+31
-17
@@ -131,7 +131,11 @@ function renderPageIndex(total: number): Html {
|
||||
</details>`;
|
||||
}
|
||||
|
||||
function renderOlderMenus(section: SectionConfig, archives: readonly StoredSet[], currentHref: string): Html {
|
||||
function renderOlderMenus(
|
||||
section: SectionConfig,
|
||||
archives: readonly StoredSet[],
|
||||
currentHref: string,
|
||||
): Html {
|
||||
if (archives.length === 0) return Html.raw('');
|
||||
return html`<details class="fold fold--older">
|
||||
<summary>Older menus</summary>
|
||||
@@ -173,11 +177,13 @@ export function renderLanding(input: LandingInput): Html {
|
||||
${join(
|
||||
input.live.map(
|
||||
(entry) => html`<a class="shelf" href="/${entry.section.slug}/">
|
||||
${entry.cover === null
|
||||
? ''
|
||||
: html`<span class="shelf__peek" aria-hidden="true"
|
||||
${
|
||||
entry.cover === null
|
||||
? ''
|
||||
: html`<span class="shelf__peek" aria-hidden="true"
|
||||
><img src="${entry.cover}" alt="" loading="lazy" decoding="async"
|
||||
/></span>`}
|
||||
/></span>`
|
||||
}
|
||||
<span class="shelf__name">${entry.section.label}</span>
|
||||
<span class="leader" aria-hidden="true"></span>
|
||||
<span class="shelf__pages">${pageCountLabel(entry.pages)}</span>
|
||||
@@ -197,9 +203,11 @@ export function renderLanding(input: LandingInput): Html {
|
||||
body: html`<main class="landing">
|
||||
<header class="masthead">
|
||||
<h1 class="wordmark">${config.brand.name}</h1>
|
||||
${config.brand.tagline === null
|
||||
? ''
|
||||
: html`<p class="masthead__tagline">${config.brand.tagline}</p>`}
|
||||
${
|
||||
config.brand.tagline === null
|
||||
? ''
|
||||
: html`<p class="masthead__tagline">${config.brand.tagline}</p>`
|
||||
}
|
||||
</header>
|
||||
${rows}
|
||||
</main>`,
|
||||
@@ -224,21 +232,27 @@ export function renderSectionPage(input: SectionInput): Html {
|
||||
const total = set.manifest.pages.length;
|
||||
|
||||
const body = html`${renderBar(config, section.slug, input.menu)}
|
||||
${input.preview
|
||||
? html`<p class="notice notice--admin">
|
||||
${
|
||||
input.preview
|
||||
? html`<p class="notice notice--admin">
|
||||
Preview of a Draft — customers cannot see this yet.
|
||||
<a href="/admin/sets/${set.id}">Back to the editor</a>
|
||||
</p>`
|
||||
: ''}
|
||||
: ''
|
||||
}
|
||||
<h2 class="section-heading">
|
||||
<span class="section-heading__label">${set.manifest.label}</span>
|
||||
${config.display.showDates
|
||||
? html`<span class="section-heading__date">${setDateLabel(set.manifest)}</span>`
|
||||
: ''}
|
||||
${
|
||||
config.display.showDates
|
||||
? html`<span class="section-heading__date">${setDateLabel(set.manifest)}</span>`
|
||||
: ''
|
||||
}
|
||||
</h2>
|
||||
${input.current || input.preview
|
||||
? ''
|
||||
: html`<p class="notice">This is an older menu. <a href="${currentHref}">See the current menu</a>.</p>`}
|
||||
${
|
||||
input.current || input.preview
|
||||
? ''
|
||||
: html`<p class="notice">This is an older menu. <a href="${currentHref}">See the current menu</a>.</p>`
|
||||
}
|
||||
${renderPages(section, set.manifest.pages)}
|
||||
<footer class="foot">
|
||||
${renderPageIndex(total)}
|
||||
|
||||
+16
-5
@@ -66,7 +66,10 @@ 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 { sections, store } = deps;
|
||||
const site = (): SiteConfig => deps.identity.apply(deps.config);
|
||||
const throttle = deps.throttle ?? new LoginThrottle();
|
||||
@@ -154,7 +157,10 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
|
||||
.code(429)
|
||||
.header('retry-after', String(verdict.retryAfterSeconds))
|
||||
.type(HTML)
|
||||
.send(renderLogin(site(), `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;
|
||||
@@ -205,7 +211,10 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
|
||||
app.post('/admin/sets', async (request, reply) => {
|
||||
if (!authed(request)) return reply.redirect('/admin/login', 303);
|
||||
if (!sameOrigin(request) || !csrfOk(request)) {
|
||||
return reply.code(403).type(HTML).send('That request was rejected. Reload the page and try again.');
|
||||
return reply
|
||||
.code(403)
|
||||
.type(HTML)
|
||||
.send('That request was rejected. Reload the page and try again.');
|
||||
}
|
||||
const body = request.body as { section?: string; label?: string; validUntil?: string };
|
||||
const section = sections.bySlug(body.section ?? '');
|
||||
@@ -213,7 +222,8 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
|
||||
try {
|
||||
const created = await store.create(section.slug, {
|
||||
label: body.label ?? `${section.label} Menu`,
|
||||
validUntil: body.validUntil === undefined || body.validUntil === '' ? null : body.validUntil,
|
||||
validUntil:
|
||||
body.validUntil === undefined || body.validUntil === '' ? null : body.validUntil,
|
||||
});
|
||||
return reply.redirect(`/admin/sets/${created.id}`, 303);
|
||||
} catch (error) {
|
||||
@@ -437,7 +447,8 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
|
||||
}
|
||||
|
||||
if (!authorised) return reply.code(403).send('Rejected.');
|
||||
if (tooLarge) return backWith(request, reply, { error: 'That file is larger than the upload limit.' });
|
||||
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 {
|
||||
|
||||
@@ -9,12 +9,7 @@ import type { SectionStore } from '../storage/sections.ts';
|
||||
import { SESSION_COOKIE, verifySessionToken } from '../auth.ts';
|
||||
import { variantFileNames } from '../storage/manifest.ts';
|
||||
import type { SetStore } from '../storage/sets.ts';
|
||||
import {
|
||||
menuLinks,
|
||||
renderLanding,
|
||||
renderNotFound,
|
||||
renderSectionPage,
|
||||
} from '../render/pages.ts';
|
||||
import { menuLinks, renderLanding, renderNotFound, renderSectionPage } from '../render/pages.ts';
|
||||
|
||||
const HTML = 'text/html; charset=utf-8';
|
||||
const MEDIA_NAME = /^(thumb-)?([0-9a-f-]{36})\.([0-9a-f]{8,64})\.(webp|jpg)$/;
|
||||
@@ -158,7 +153,10 @@ export async function registerPublicRoutes(
|
||||
if (draft && !isAuthed(request)) return reply.code(404).send();
|
||||
|
||||
const names = variantFileNames(entry.page);
|
||||
const file = path.join(entry.set.dir, name.thumb ? names.thumb : name.ext === 'webp' ? names.webp : names.jpeg);
|
||||
const file = path.join(
|
||||
entry.set.dir,
|
||||
name.thumb ? names.thumb : name.ext === 'webp' ? names.webp : names.jpeg,
|
||||
);
|
||||
let size: number;
|
||||
try {
|
||||
size = (await stat(file)).size;
|
||||
@@ -167,7 +165,10 @@ export async function registerPublicRoutes(
|
||||
}
|
||||
|
||||
return reply
|
||||
.header('cache-control', draft ? 'private, max-age=60' : 'public, max-age=31536000, immutable')
|
||||
.header(
|
||||
'cache-control',
|
||||
draft ? 'private, max-age=60' : 'public, max-age=31536000, immutable',
|
||||
)
|
||||
.header('etag', `"${entry.page.hash}-${name.thumb ? 'thumb' : name.ext}"`)
|
||||
.header('content-length', String(size))
|
||||
.type(name.thumb || name.ext === 'webp' ? 'image/webp' : 'image/jpeg')
|
||||
|
||||
+2
-1
@@ -136,7 +136,8 @@ export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
|
||||
|
||||
app.setErrorHandler(async (error: FastifyError, request, reply) => {
|
||||
request.log.error({ err: error }, 'request failed');
|
||||
const status = error.statusCode !== undefined && error.statusCode < 500 ? error.statusCode : 500;
|
||||
const status =
|
||||
error.statusCode !== undefined && error.statusCode < 500 ? error.statusCode : 500;
|
||||
return reply.code(status).type('text/plain; charset=utf-8').send('Something went wrong.');
|
||||
});
|
||||
|
||||
|
||||
@@ -145,7 +145,11 @@ export class IdentityStore {
|
||||
};
|
||||
}
|
||||
|
||||
async update(patch: { name?: string; tagline?: string | null; showDates?: boolean }): Promise<SiteIdentity> {
|
||||
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'),
|
||||
|
||||
@@ -91,7 +91,8 @@ function parsePage(raw: unknown, where: string): PageRecord {
|
||||
if (typeof height !== 'number' || !Number.isSafeInteger(height) || height <= 0) {
|
||||
throw new ManifestError(`${where}.height must be a positive whole number`);
|
||||
}
|
||||
if (typeof originalName !== 'string') throw new ManifestError(`${where}.originalName must be a string`);
|
||||
if (typeof originalName !== 'string')
|
||||
throw new ManifestError(`${where}.originalName must be a string`);
|
||||
return { id, sourceExt: sourceExt as ImageExt, hash, width, height, originalName };
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,9 @@ export async function repairSite(options: RepairOptions): Promise<RepairReport>
|
||||
|
||||
const originalPath = path.join(originals, originalFileName(page));
|
||||
if (!(await exists(originalPath))) {
|
||||
report.unresolved.push(`${where}: ${missing.length} file(s) missing and the Original is gone`);
|
||||
report.unresolved.push(
|
||||
`${where}: ${missing.length} file(s) missing and the Original is gone`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -67,10 +67,14 @@ function parse(raw: unknown): SectionConfig[] {
|
||||
return {
|
||||
slug,
|
||||
label,
|
||||
shortLabel: typeof candidate['shortLabel'] === 'string' && candidate['shortLabel'] !== ''
|
||||
? candidate['shortLabel']
|
||||
: label,
|
||||
note: typeof candidate['note'] === 'string' && candidate['note'] !== '' ? candidate['note'] : null,
|
||||
shortLabel:
|
||||
typeof candidate['shortLabel'] === 'string' && candidate['shortLabel'] !== ''
|
||||
? candidate['shortLabel']
|
||||
: label,
|
||||
note:
|
||||
typeof candidate['note'] === 'string' && candidate['note'] !== ''
|
||||
? candidate['note']
|
||||
: null,
|
||||
order,
|
||||
} satisfies SectionConfig;
|
||||
});
|
||||
|
||||
+8
-6
@@ -105,7 +105,10 @@ async function listDirectoryNames(dir: string): Promise<string[]> {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
||||
throw error;
|
||||
}
|
||||
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
|
||||
return entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
async function rmrf(target: string): Promise<void> {
|
||||
@@ -227,10 +230,7 @@ export class SetStore {
|
||||
});
|
||||
}
|
||||
|
||||
async addPage(
|
||||
setId: string,
|
||||
input: { data: Buffer; originalName: string },
|
||||
): Promise<PageRecord> {
|
||||
async addPage(setId: string, input: { data: Buffer; originalName: string }): Promise<PageRecord> {
|
||||
return this.exclusive(async () => {
|
||||
const set = await this.requireDraft(setId);
|
||||
if (set.manifest.pages.length >= this.maxPagesPerSet) {
|
||||
@@ -343,7 +343,9 @@ export class SetStore {
|
||||
throw new StoreError('a Menu Set needs at least one Page before it can be published', 409);
|
||||
}
|
||||
const publishedAt = this.now();
|
||||
const taken = new Set(await listDirectoryNames(path.join(this.paths.published, set.manifest.section)));
|
||||
const taken = new Set(
|
||||
await listDirectoryNames(path.join(this.paths.published, set.manifest.section)),
|
||||
);
|
||||
const base = set.manifest.validUntil ?? this.calendarDate(publishedAt);
|
||||
let slug = base;
|
||||
for (let suffix = 2; taken.has(slug); suffix += 1) slug = `${base}-${suffix}`;
|
||||
|
||||
+12
-3
@@ -31,7 +31,10 @@ async function exists(target: string): Promise<boolean> {
|
||||
async function listDirectoryNames(dir: string): Promise<string[]> {
|
||||
try {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
|
||||
return entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
||||
throw error;
|
||||
@@ -80,7 +83,10 @@ export async function validateSite(options: ValidateOptions): Promise<Problem[]>
|
||||
problems.push({ level: 'fatal', message: `${where}: Section is not in the Site config` });
|
||||
}
|
||||
if (set.manifest.section !== path.basename(path.dirname(set.dir))) {
|
||||
problems.push({ level: 'fatal', message: `${where}: manifest section does not match its directory` });
|
||||
problems.push({
|
||||
level: 'fatal',
|
||||
message: `${where}: manifest section does not match its directory`,
|
||||
});
|
||||
}
|
||||
if (set.state === 'published' && set.manifest.publishedAt === null) {
|
||||
problems.push({ level: 'fatal', message: `${where}: published set has no publishedAt` });
|
||||
@@ -119,7 +125,10 @@ export async function validateSite(options: ValidateOptions): Promise<Problem[]>
|
||||
const key = set.manifest.section;
|
||||
const seen = slugs.get(key) ?? new Set<string>();
|
||||
if (seen.has(set.slug)) {
|
||||
problems.push({ level: 'fatal', message: `two published sets share the URL ${key}/${set.slug}` });
|
||||
problems.push({
|
||||
level: 'fatal',
|
||||
message: `two published sets share the URL ${key}/${set.slug}`,
|
||||
});
|
||||
}
|
||||
seen.add(set.slug);
|
||||
slugs.set(key, seen);
|
||||
|
||||
Reference in new issue
Block a user