Files
menu/src/server.ts
T
tanshu a635c5a214 Answer an oversized photo with the reason, not a 500
A file past the size limit makes the multipart stream itself throw, which
escaped the per-file catch and reached the error handler: the phone that just
sent a 35 MB photo got 'Something went wrong'. Catch the limit errors around the
part loop, say which limit was hit, and report it once rather than twice. Also
asserts that derivatives carry no EXIF while the Original keeps the uploaded
bytes.
2026-09-22 21:41:12 +05:30

195 lines
6.1 KiB
TypeScript

/**
* Wires the Site config, the Menu Set store and the routes into a Fastify app.
* `buildApp` is what the tests drive; `main` is what the container runs.
*/
import { randomBytes } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import Fastify, { type FastifyError, type FastifyInstance } from 'fastify';
import cookie from '@fastify/cookie';
import formbody from '@fastify/formbody';
import multipart from '@fastify/multipart';
import fastifyStatic from '@fastify/static';
import { parseSiteConfig, type SiteConfig } from './config.ts';
import { SESSION_COOKIE, sessionTtl, type LoginThrottle } from './auth.ts';
import { createImagePipeline } from './images/pipeline.ts';
import { SetStore, type ImagePipeline } from './storage/sets.ts';
import { hasFatal, validateSite } from './storage/validate.ts';
import { renderNotFound } from './render/pages.ts';
import { registerPublicRoutes } from './routes/public.ts';
import { registerAdminRoutes } from './routes/admin.ts';
import { readEnvFile } from './env.ts';
export const SESSION_TTL_MS = 8 * 60 * 60 * 1000;
/** What every route reaches for: the store, the session secret, the admin hash. */
export interface MenuContext {
store: SetStore;
sessionSecret: string;
sessionTtlMs: number;
adminPasswordHash: string | null;
secureCookies: boolean;
}
declare module 'fastify' {
interface FastifyInstance {
menu: MenuContext;
}
}
export interface AppOptions {
config: SiteConfig;
dataDir: string;
sessionSecret: string;
/** Admin is disabled, with a warning, when this is null. */
adminPasswordHash: string | null;
assetsDir: string;
secureCookies: boolean;
logLevel: string;
/** Test seam: defaults to the sharp pipeline. */
pipeline?: ImagePipeline;
/** Test seam: defaults to the real clock. */
now?: () => Date;
/** Test seam: a shared throttle so rate-limit tests are deterministic. */
throttle?: LoginThrottle;
}
export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
const app = Fastify({
routerOptions: { ignoreTrailingSlash: true },
trustProxy: true,
logger: { level: options.logLevel },
});
await app.register(cookie);
await app.register(formbody);
await app.register(multipart, {
limits: {
fileSize: options.config.limits.maxUploadBytes,
files: options.config.limits.maxPagesPerSet,
},
});
await app.register(fastifyStatic, {
root: options.assetsDir,
prefix: '/assets/',
index: false,
cacheControl: true,
maxAge: '1h',
});
const store = new SetStore({
dataDir: options.dataDir,
pipeline: options.pipeline ?? createImagePipeline(),
sections: options.config.sections.map((section) => section.slug),
maxPagesPerSet: options.config.limits.maxPagesPerSet,
now: options.now,
});
await registerPublicRoutes(app, {
config: options.config,
store,
sessionSecret: options.sessionSecret,
});
await registerAdminRoutes(app, {
config: options.config,
store,
sessionSecret: options.sessionSecret,
adminPasswordHash: options.adminPasswordHash,
secureCookies: options.secureCookies,
maxUploadBytes: options.config.limits.maxUploadBytes,
throttle: options.throttle,
});
app.setNotFoundHandler(async (_request, reply) => {
const current = await store.currentBySection();
const live = options.config.sections.filter((section) => current.has(section.slug));
return reply
.code(404)
.type('text/html; charset=utf-8')
.send(renderNotFound(options.config, live).value);
});
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;
return reply.code(status).type('text/plain; charset=utf-8').send('Something went wrong.');
});
app.decorate('menu', {
store,
sessionSecret: options.sessionSecret,
sessionTtlMs: SESSION_TTL_MS,
adminPasswordHash: options.adminPasswordHash,
secureCookies: options.secureCookies,
});
return app;
}
export function sessionCookieAttributes(secure: boolean) {
return {
name: SESSION_COOKIE,
path: '/',
httpOnly: true,
sameSite: 'lax' as const,
secure,
maxAge: Math.floor(sessionTtl(SESSION_TTL_MS) / 1000),
};
}
async function main(): Promise<void> {
const root = path.resolve(process.env['MENU_ROOT'] ?? '.dev');
const configPath = path.resolve(
process.env['MENU_SITE_CONFIG'] ?? 'config/hopsngrains.site.json',
);
const assetsDir = path.join(import.meta.dirname, '..', 'public');
const dataDir = path.join(root, 'data');
const config = parseSiteConfig(JSON.parse(await readFile(configPath, 'utf8')));
const env = await readEnvFile(path.join(root, 'site.env'));
let sessionSecret = env['SESSION_SECRET'] ?? '';
if (sessionSecret === '') {
sessionSecret = randomBytes(32).toString('base64url');
console.warn('SESSION_SECRET is not set; using an ephemeral secret (logins reset on restart)');
}
const adminPasswordHash = env['ADMIN_PASSWORD_HASH'] ?? null;
if (adminPasswordHash === null) {
console.warn('ADMIN_PASSWORD_HASH is not set; the admin is disabled');
}
const problems = await validateSite({
dataDir,
sections: config.sections.map((section) => section.slug),
});
for (const problem of problems) {
const line = `${problem.level}: ${problem.message}`;
if (problem.level === 'fatal') console.error(line);
else console.warn(line);
}
if (hasFatal(problems)) {
console.error('refusing to serve a data directory with fatal integrity problems');
process.exitCode = 1;
return;
}
const app = await buildApp({
config,
dataDir,
sessionSecret,
adminPasswordHash,
assetsDir,
secureCookies: (process.env['SECURE_COOKIES'] ?? 'true') !== 'false',
logLevel: process.env['LOG_LEVEL'] ?? 'info',
});
const port = Number(process.env['PORT'] ?? 3000);
await app.listen({ port, host: process.env['HOST'] ?? '0.0.0.0' });
}
if (process.argv[1] !== undefined && import.meta.filename === process.argv[1]) {
await main();
}