import Fastify, { LogController } from 'fastify'; import fastifyStatic from '@fastify/static'; import rateLimitPlugin from '@fastify/rate-limit'; import { realpath, stat } from 'node:fs/promises'; import { basename, extname, join, relative, sep } from 'node:path'; import { isInside, parsePublicOrigin } from './config.mjs'; const VISITOR_PATTERN = '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'; const DOG_PATTERN = '^[0-9a-f]{64}$'; const isApi = path => path === '/api' || path.startsWith('/api/'); const errorNames = { 400: 'Invalid request', 403: 'Forbidden', 404: 'Not found', 413: 'Request too large', 415: 'Unsupported media type', 429: 'Too many requests', 503: 'Service unavailable' }; function sendError(reply, status) { return reply.code(status).header('Cache-Control', 'no-store').send({ error: errorNames[status] ?? 'Request failed' }); } function bodySchema(key, pattern, length) { return { type: 'object', additionalProperties: false, required: [key], properties: { [key]: { type: 'string', pattern, minLength: length, maxLength: length } } }; } function blockedPath(path) { const parts = path.split('/').filter(Boolean); return parts.some(p => p === '..' || p.startsWith('.') || /\.(?:sqlite3?|db)(?:-(?:wal|shm|journal))?$/i.test(p)) || ['server', 'node_modules'].includes(parts[0]) || /^(?:config(?:\..*)?|package(?:-lock)?\.json)$/i.test(basename(path)); } /** Build an injectable app only; this module never opens a listener. */ export async function buildApp({ store, distRoot, publicOrigin, rateLimit = { max: 60, timeWindow: 60000 } }) { const origin = parsePublicOrigin(publicOrigin); const root = await realpath(distRoot); const app = Fastify({ logger: false, logController: new LogController({ disableRequestLogging: true }), trustProxy: false, bodyLimit: 1024, frameworkErrors: (_error, _request, reply) => sendError(reply, 400), ajv: { customOptions: { removeAdditional: false, coerceTypes: false } } }); let closed = false; app.addHook('onClose', async () => { if (!closed) { closed = true; store.close(); } }); app.setErrorHandler((error, _request, reply) => { const status = error.validation ? 400 : [400, 403, 404, 413, 415, 429].includes(error.statusCode) ? error.statusCode : 503; return sendError(reply, status); }); app.addHook('onRequest', async (request, reply) => { // This same-origin service accepts origin-form targets only. Fastify also // routes absolute-form targets, which must not escape path-based guards. if (!request.raw.url.startsWith('/') || request.raw.url.startsWith('//')) return sendError(reply, 400); const rawPath = request.raw.url.split('?')[0]; let path; try { path = decodeURIComponent(rawPath); } catch { return sendError(reply, 400); } if (path.includes('\\') || path.includes('\0') || blockedPath(path)) return sendError(reply, 404); if (isApi(path)) { reply.header('Cache-Control', 'no-store'); // Origin is abuse friction, not authentication. Missing Origin is permitted. if (request.headers.origin !== undefined && request.headers.origin !== origin) return sendError(reply, 403); } }); await app.register(rateLimitPlugin, { global: true, max: rateLimit.max, timeWindow: rateLimit.timeWindow, allowList: request => !isApi(decodeURIComponent(request.raw.url.split('?')[0])), keyGenerator: request => request.ip, errorResponseBuilder: () => Object.assign(new Error('Too many requests'), { statusCode: 429 }), }); // Fastify's default text parser would otherwise accept text/plain before schema validation. app.removeContentTypeParser('text/plain'); await app.register(fastifyStatic, { root, serve: false, dotfiles: 'deny', cacheControl: false, setHeaders(reply, path) { const name = basename(path); // Vite writes generated bundles/images directly under assets with an eight-character hash. // Copied public assets in nested folders (notably existing GLB models) remain revalidated. const hashedBuildAsset = /^assets[\\/][^\\/]+-[A-Za-z0-9_-]{8}\.(?:m?js|css|png|jpe?g|webp|svg|woff2?|wasm)$/.test(relative(root, path)); reply.header('Cache-Control', /\.html?$/i.test(name) ? 'no-store' : hashedBuildAsset ? 'public,max-age=31536000,immutable' : 'no-cache'); }, }); app.post('/api/visits', { schema: { body: bodySchema('visitor_id', VISITOR_PATTERN, 36) } }, async (request, reply) => { store.recordVisit(request.body.visitor_id); return reply.code(204).send(); }); app.post('/api/arrivals', { schema: { body: bodySchema('dog_id', DOG_PATTERN, 64) } }, async (request, reply) => { store.recordArrival(request.body.dog_id); return reply.code(204).send(); }); app.get('/api/arrivals/count', async (_request, reply) => { const count = store.arrivalCount(); if (!Number.isSafeInteger(count) || count < 0) throw new Error('Invalid aggregate'); return reply.send({ count }); }); const notFound = (_request, reply) => sendError(reply, 404); app.all('/api', { onRequest: notFound }, notFound); app.all('/api/*', { onRequest: notFound }, notFound); app.setNotFoundHandler(notFound); app.get('/*', async (request, reply) => { const path = decodeURIComponent(request.raw.url.split('?')[0]); if (isApi(path)) return sendError(reply, 404); const requested = path === '/' ? '/index.html' : path; let file; try { file = await realpath(join(root, requested)); if (!isInside(root, file) || !(await stat(file)).isFile()) return sendError(reply, 404); } catch (error) { if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error; const acceptsHTML = (request.headers.accept ?? '').split(',').some(value => /^text\/html(?:\s*;|\s*$)/i.test(value.trim()) && !/;\s*q=0(?:\.0*)?(?:\s*;|\s*$)/i.test(value)); if (!acceptsHTML || extname(path) || /^\/(?:assets|fonts|models|workers|wasm)(?:\/|$)/.test(path)) return sendError(reply, 404); file = await realpath(join(root, 'index.html')); if (!isInside(root, file)) return sendError(reply, 404); } const filePath = relative(root, file).split(sep).join('/'); if (blockedPath(filePath)) return sendError(reply, 404); return reply.sendFile(filePath); }); await app.ready(); return app; }