diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ff65d41 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +.npm-cache +.tmp +.dev +.git +public/fonts/OFL.txt +docs +test +compose.yaml +Dockerfile +.dockerignore +README.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6779e72 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# The app runs TypeScript directly on Node 24, so there is no build step and no +# compiler in the image. Node 24 on Alpine is musl, which sharp ships prebuilt +# binaries for. +FROM node:24-alpine + +ENV NODE_ENV=production +WORKDIR /app + +# Dependencies first, so editing source does not reinstall them. +COPY --chown=node:node package.json package-lock.json ./ +RUN npm ci --omit=dev && npm cache clean --force + +COPY --chown=node:node src ./src +COPY --chown=node:node public ./public +COPY --chown=node:node config ./config +COPY --chown=node:node scripts ./scripts + +# Config is mounted read-only; everything the app writes lives under MENU_ROOT. +ENV MENU_ROOT=/data +ENV MENU_SITE_CONFIG=/etc/menu-app/site.json +ENV PORT=3000 + +RUN mkdir -p /data && chown node:node /data +USER node + +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \ + CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +CMD ["node", "src/server.ts"] diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..4df6001 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,19 @@ +# Local development. Production is the menu-admin playbook in ansible-playbooks; +# this file exists so the container can be tried out without touching a server. +# +# npm run seed && docker compose up --build +services: + menu: + build: . + image: menu-admin:dev + ports: + - "3000:3000" + environment: + MENU_ROOT: /data + MENU_SITE_CONFIG: /etc/menu-app/site.json + SECURE_COOKIES: "false" + LOG_LEVEL: info + volumes: + - ./config/hopsngrains.site.json:/etc/menu-app/site.json:ro + - ./.dev:/data + restart: unless-stopped diff --git a/package.json b/package.json index ea2c597..ffc2056 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "start": "node src/server.ts", "dev": "SECURE_COOKIES=false node --watch src/server.ts", "seed": "node scripts/seed-dev.ts", + "verify": "node src/cli/verify.ts", + "repair": "node src/cli/repair.ts", "typecheck": "tsc --noEmit", "test": "node --test test/*.test.ts" }, diff --git a/scripts/provision-site-env.sh b/scripts/provision-site-env.sh new file mode 100755 index 0000000..316f556 --- /dev/null +++ b/scripts/provision-site-env.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# +# Writes the one file that never goes in git: the admin password hash and the +# session secret. Run this on the server, once per Site. +# +# scripts/provision-site-env.sh /var/lib/menu-app.hopsngrains.com +# +# The hash is produced by the app image itself, so the server needs no Node and +# the password is never visible in the process list. +set -euo pipefail + +usage() { + cat <<'USAGE' +usage: provision-site-env.sh [image] [--force] + + directory the app mounts as MENU_ROOT, e.g. + /var/lib/menu-app.hopsngrains.com + [image] app image to borrow Node from (default menu-admin:latest) + --force overwrite an existing site.env +USAGE +} + +DATA_ROOT="${1:-}" +if [[ -z "$DATA_ROOT" || "$DATA_ROOT" == "-h" || "$DATA_ROOT" == "--help" ]]; then + usage + exit 1 +fi +shift || true + +IMAGE="menu-admin:latest" +FORCE=0 +for argument in "$@"; do + case "$argument" in + --force) FORCE=1 ;; + *) IMAGE="$argument" ;; + esac +done + +ENV_FILE="$DATA_ROOT/site.env" + +if [[ -f "$ENV_FILE" && "$FORCE" -ne 1 ]]; then + echo "$ENV_FILE already exists; pass --force to replace it" >&2 + exit 1 +fi + +if ! command -v docker >/dev/null 2>&1; then + echo "docker is required" >&2 + exit 1 +fi + +if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then + echo "image $IMAGE is not present; pull or build it first" >&2 + exit 1 +fi + +read -r -s -p "Admin password: " PASSWORD +echo +read -r -s -p "Repeat password: " REPEAT +echo + +if [[ -z "$PASSWORD" ]]; then + echo "the password cannot be empty" >&2 + exit 1 +fi +if [[ "$PASSWORD" != "$REPEAT" ]]; then + echo "the passwords do not match" >&2 + exit 1 +fi +if [[ ${#PASSWORD} -lt 10 ]]; then + echo "use at least 10 characters" >&2 + exit 1 +fi + +HASH="$(printf '%s' "$PASSWORD" | docker run --rm -i --entrypoint node "$IMAGE" \ + --input-type=module \ + -e "let s='';process.stdin.setEncoding('utf8');process.stdin.on('data',c=>s+=c);process.stdin.on('end',async()=>{const {pathToFileURL}=await import('node:url');const m=await import(pathToFileURL('/app/src/auth.ts').href);process.stdout.write(await m.hashPassword(s))})")" + +SECRET="$(docker run --rm --entrypoint node "$IMAGE" --input-type=module \ + -e "import {randomBytes} from 'node:crypto';process.stdout.write(randomBytes(32).toString('base64url'))")" + +if [[ -z "$HASH" || -z "$SECRET" ]]; then + echo "the image failed to produce the hash or the secret" >&2 + exit 1 +fi + +mkdir -p "$DATA_ROOT" +umask 077 +printf 'ADMIN_PASSWORD_HASH=%s\nSESSION_SECRET=%s\n' "$HASH" "$SECRET" > "$ENV_FILE" +unset PASSWORD REPEAT + +echo "wrote $ENV_FILE (mode $(stat -c '%a' "$ENV_FILE" 2>/dev/null || echo 600))" +echo "now (re)start the app container; changing the password later means running this again with --force" diff --git a/src/cli/repair.ts b/src/cli/repair.ts new file mode 100644 index 0000000..5adb762 --- /dev/null +++ b/src/cli/repair.ts @@ -0,0 +1,17 @@ +/** + * Regenerates derivatives that are missing while their Original is intact. + * + * npm run repair + */ + +import { repairSite } from '../storage/repair.ts'; +import { loadCliSite } from './site.ts'; + +const { dataDir } = await loadCliSite(); +const report = await repairSite({ dataDir }); + +for (const line of report.fixed) console.log(`fixed: ${line}`); +for (const line of report.unresolved) console.error(`unresolved: ${line}`); +console.log(`${report.fixed.length} file(s) repaired, ${report.unresolved.length} unresolved`); + +if (report.unresolved.length > 0) process.exitCode = 1; diff --git a/src/cli/site.ts b/src/cli/site.ts new file mode 100644 index 0000000..3cafce9 --- /dev/null +++ b/src/cli/site.ts @@ -0,0 +1,33 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { ConfigError, parseSiteConfig, type SiteConfig } from '../config.ts'; + +export interface CliSite { + config: SiteConfig; + root: string; + dataDir: string; +} + +/** The same environment variables the server reads, for the CLI commands. */ +export async function loadCliSite(): Promise { + const root = path.resolve(process.env['MENU_ROOT'] ?? '.dev'); + const configPath = path.resolve( + process.env['MENU_SITE_CONFIG'] ?? 'config/hopsngrains.site.json', + ); + let raw: unknown; + try { + raw = JSON.parse(await readFile(configPath, 'utf8')); + } catch (error) { + throw new ConfigError(`could not read ${configPath}: ${(error as Error).message}`); + } + return { + config: parseSiteConfig(raw), + root, + dataDir: path.join(root, 'data'), + }; +} + +export function fail(message: string): never { + console.error(message); + process.exit(1); +} diff --git a/src/cli/verify.ts b/src/cli/verify.ts new file mode 100644 index 0000000..aad552b --- /dev/null +++ b/src/cli/verify.ts @@ -0,0 +1,24 @@ +/** + * Reports integrity problems in the data directory without changing anything. + * Fatal problems are what the server refuses to start on. + * + * npm run verify + */ + +import { hasFatal, validateSite } from '../storage/validate.ts'; +import { loadCliSite } from './site.ts'; + +const { config, dataDir } = await loadCliSite(); +const problems = await validateSite({ + dataDir, + sections: config.sections.map((section) => section.slug), +}); + +if (problems.length === 0) { + console.log(`ok: ${dataDir}`); +} else { + for (const problem of problems) console.log(`${problem.level}: ${problem.message}`); + console.log(`${problems.length} problem(s) in ${dataDir}`); +} + +if (hasFatal(problems)) process.exitCode = 1; diff --git a/src/server.ts b/src/server.ts index 9071e53..0e4b629 100644 --- a/src/server.ts +++ b/src/server.ts @@ -57,7 +57,7 @@ export interface AppOptions { export async function buildApp(options: AppOptions): Promise { const app = Fastify({ - ignoreTrailingSlash: true, + routerOptions: { ignoreTrailingSlash: true }, trustProxy: true, logger: { level: options.logLevel }, }); diff --git a/src/storage/repair.ts b/src/storage/repair.ts new file mode 100644 index 0000000..2afab24 --- /dev/null +++ b/src/storage/repair.ts @@ -0,0 +1,78 @@ +/** + * Regenerates missing derivatives from the Originals. Originals are kept + * exactly so that a lost Viewing image or Thumbnail is recoverable, which is + * what makes the file layout the source of truth rather than the manifests. + */ + +import { access, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { createImagePipeline } from '../images/pipeline.ts'; +import { originalFileName, variantFileNames } from './manifest.ts'; +import { sitePaths, type ImagePipeline, type StoredSet } from './sets.ts'; +import { readSets } from './validate.ts'; + +export interface RepairReport { + fixed: string[]; + unresolved: string[]; +} + +async function exists(target: string): Promise { + try { + await access(target); + return true; + } catch { + return false; + } +} + +export interface RepairOptions { + dataDir: string; + pipeline?: ImagePipeline; +} + +export async function repairSite(options: RepairOptions): Promise { + const paths = sitePaths(options.dataDir); + const pipeline = options.pipeline ?? createImagePipeline(); + const report: RepairReport = { fixed: [], unresolved: [] }; + const { sets } = await readSets(options.dataDir); + + for (const set of sets as StoredSet[]) { + const originals = path.join(paths.originals, set.section, set.id); + for (const [index, page] of set.manifest.pages.entries()) { + const names = variantFileNames(page); + const where = `${set.state}/${set.section}/${set.slug ?? set.id} page ${index + 1} (${page.id})`; + const wanted: { file: string; key: 'webp' | 'jpeg' | 'thumb' }[] = [ + { file: path.join(set.dir, names.webp), key: 'webp' }, + { file: path.join(set.dir, names.jpeg), key: 'jpeg' }, + { file: path.join(set.dir, names.thumb), key: 'thumb' }, + ]; + + const missing = []; + for (const candidate of wanted) { + if (!(await exists(candidate.file))) missing.push(candidate); + } + if (missing.length === 0) continue; + + 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`); + continue; + } + + const data = await readFile(originalPath); + const derivatives = await pipeline.process(data, page.originalName); + if (derivatives.hash !== page.hash) { + report.unresolved.push( + `${where}: the Original no longer matches the manifest hash (${page.hash} → ${derivatives.hash}); re-upload the Page`, + ); + continue; + } + for (const candidate of missing) { + await writeFile(candidate.file, derivatives[candidate.key]); + report.fixed.push(`${where}: rewrote ${path.basename(candidate.file)}`); + } + } + } + + return report; +} diff --git a/src/storage/validate.ts b/src/storage/validate.ts index c9edfb9..1c59d91 100644 --- a/src/storage/validate.ts +++ b/src/storage/validate.ts @@ -37,8 +37,14 @@ async function listDirectoryNames(dir: string): Promise { } } -export async function validateSite(options: ValidateOptions): Promise { - const paths = sitePaths(options.dataDir); +export interface SiteContents { + sets: StoredSet[]; + problems: Problem[]; +} + +/** Reads every set directory once; shared by validation and repair. */ +export async function readSets(dataDir: string): Promise { + const paths = sitePaths(dataDir); const problems: Problem[] = []; const sets: StoredSet[] = []; @@ -60,6 +66,13 @@ export async function validateSite(options: ValidateOptions): Promise } } + return { sets, problems }; +} + +export async function validateSite(options: ValidateOptions): Promise { + const paths = sitePaths(options.dataDir); + const { sets, problems } = await readSets(options.dataDir); + for (const set of sets) { const where = `${set.state}/${set.manifest.section}/${set.slug ?? set.manifest.id}`; if (!options.sections.includes(set.manifest.section)) { diff --git a/test/repair.test.ts b/test/repair.test.ts new file mode 100644 index 0000000..1a85d2f --- /dev/null +++ b/test/repair.test.ts @@ -0,0 +1,95 @@ +import { strict as assert } from 'node:assert'; +import { readdir, readFile, rm, unlink, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, it } from 'node:test'; +import { sitePaths } from '../src/storage/sets.ts'; +import { repairSite } from '../src/storage/repair.ts'; +import { hasFatal, validateSite } from '../src/storage/validate.ts'; +import { FakePipeline, SECTIONS, makeStore, pngBytes, tempDir } from './support/harness.ts'; + +let dataDir: string; + +beforeEach(async () => { + dataDir = await tempDir('repair'); +}); + +afterEach(async () => { + await rm(dataDir, { recursive: true, force: true }); +}); + +async function publishedSetWithPages(count = 2) { + const { store } = makeStore(dataDir); + const set = await store.create('bar', { label: 'Bar Menu', validUntil: '2026-06-26' }); + for (let index = 0; index < count; index += 1) { + await store.addPage(set.id, { data: pngBytes(`page-${index}`), originalName: `${index}.jpg` }); + } + const published = await store.publish(set.id); + return { store, set: published }; +} + +function validate() { + return validateSite({ dataDir, sections: [...SECTIONS] }); +} + +describe('repairSite', () => { + it('reports nothing to do on a healthy Site', async () => { + await publishedSetWithPages(); + const report = await repairSite({ dataDir, pipeline: new FakePipeline() }); + assert.deepEqual(report, { fixed: [], unresolved: [] }); + }); + + it('rebuilds a missing Thumbnail and a missing Viewing image from the Original', async () => { + const { set } = await publishedSetWithPages(1); + const page = set.manifest.pages[0]!; + const thumbnail = (await readdir(set.dir)).find((name) => name.startsWith('thumb-'))!; + const webp = (await readdir(set.dir)).find( + (name) => name.endsWith('.webp') && !name.startsWith('thumb-'), + )!; + const thumbBytes = await readFile(path.join(set.dir, thumbnail)); + await unlink(path.join(set.dir, thumbnail)); + await unlink(path.join(set.dir, webp)); + + assert.equal(hasFatal(await validate()), true); + + const report = await repairSite({ dataDir, pipeline: new FakePipeline() }); + assert.equal(report.fixed.length, 2); + assert.deepEqual(report.unresolved, []); + assert.deepEqual(await validate(), []); + + assert.deepEqual(await readFile(path.join(set.dir, thumbnail)), thumbBytes); + assert.equal(page.hash.length > 0, true); + }); + + it('leaves a Page unresolved when its Original is gone', async () => { + const { set } = await publishedSetWithPages(1); + const page = set.manifest.pages[0]!; + const originals = path.join(sitePaths(dataDir).originals, 'bar', set.manifest.id); + const original = (await readdir(originals))[0]!; + await unlink(path.join(originals, original)); + const thumbnail = (await readdir(set.dir)).find((name) => name.startsWith('thumb-'))!; + await unlink(path.join(set.dir, thumbnail)); + + const report = await repairSite({ dataDir, pipeline: new FakePipeline() }); + assert.equal(report.fixed.length, 0); + assert.equal(report.unresolved.length, 1); + assert.match(report.unresolved[0] ?? '', /Original is gone/); + assert.match(report.unresolved[0] ?? '', new RegExp(page.id)); + }); + + it('notices when an Original no longer matches its recorded hash', async () => { + const { set } = await publishedSetWithPages(1); + const originals = path.join(sitePaths(dataDir).originals, 'bar', set.manifest.id); + const original = (await readdir(originals))[0]!; + await rm(path.join(originals, original)); + const page = set.manifest.pages[0]!; + await writeFile(path.join(originals, original), pngBytes('swapped')); + const thumbnail = (await readdir(set.dir)).find((name) => name.startsWith('thumb-'))!; + await unlink(path.join(set.dir, thumbnail)); + + const report = await repairSite({ dataDir, pipeline: new FakePipeline() }); + assert.equal(report.fixed.length, 0); + assert.equal(report.unresolved.length, 1); + assert.match(report.unresolved[0] ?? '', /no longer matches/); + assert.equal(page.hash.length > 0, true); + }); +});