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:
1 parent
58ecfa0dc3
commit
bb9ddb437f
12 files changed
+419
-3
No files matched your search
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
.npm-cache
|
||||
.tmp
|
||||
.dev
|
||||
.git
|
||||
public/fonts/OFL.txt
|
||||
docs
|
||||
test
|
||||
compose.yaml
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
README.md
|
||||
+31
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Executable
+92
@@ -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"
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user