/** * 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 { 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 { 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(); }