Ship a repair command and a container

verify reports integrity problems and repair rebuilds derivatives from the
Originals, which is what makes the file layout trustworthy without a database.
The image runs TypeScript on Node 24 with no build step, writes only under
MENU_ROOT, and the provision script writes the untracked site.env using the
image's own hasher so the server needs no Node.
This commit is contained in:
tanshu committed 2026-09-22 21:24:47 +05:30
1 parent 58ecfa0dc3
commit bb9ddb437f
12 files changed
+419 -3

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
node_modules
.npm-cache
.tmp
.dev
.git
public/fonts/OFL.txt
docs
test
compose.yaml
Dockerfile
.dockerignore
README.md
+31
View File
@@ -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"]
+19
View File
@@ -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
+2
View File
@@ -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"
},
+92
View File
@@ -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 <data-root> [image] [--force]
<data-root> 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"
+17
View File
@@ -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;
+33
View File
@@ -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<CliSite> {
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);
}
+24
View File
@@ -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;
+1 -1
View File
@@ -57,7 +57,7 @@ export interface AppOptions {
export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
const app = Fastify({
ignoreTrailingSlash: true,
routerOptions: { ignoreTrailingSlash: true },
trustProxy: true,
logger: { level: options.logLevel },
});
+78
View File
@@ -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<boolean> {
try {
await access(target);
return true;
} catch {
return false;
}
}
export interface RepairOptions {
dataDir: string;
pipeline?: ImagePipeline;
}
export async function repairSite(options: RepairOptions): Promise<RepairReport> {
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;
}
+15 -2
View File
@@ -37,8 +37,14 @@ async function listDirectoryNames(dir: string): Promise<string[]> {
}
}
export async function validateSite(options: ValidateOptions): Promise<Problem[]> {
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<SiteContents> {
const paths = sitePaths(dataDir);
const problems: Problem[] = [];
const sets: StoredSet[] = [];
@@ -60,6 +66,13 @@ export async function validateSite(options: ValidateOptions): Promise<Problem[]>
}
}
return { sets, problems };
}
export async function validateSite(options: ValidateOptions): Promise<Problem[]> {
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)) {
+95
View File
@@ -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);
});
});