Files
horoli e358e24436 Replace project with current server release and Windows setup
Import the 2026-10-09 source release, Fastify/SQLite backend, restored source
assets, and analytics-enabled server build. Replace files from the previous
release while retaining the existing repository history.

Make builds portable across Windows and Linux, configure local startup,
normalize Windows static asset paths, and fix test fixture cleanup and file
URL handling. Exclude local environment settings, databases, dependencies,
validation output, and TypeScript build cache from version control.

Validation: npm ci, static and server builds, 106 arrival tests, and actual
local HTTP checks passed. The full suite passed 467 of 470 tests; three tests
still require QA fixtures omitted from the supplied release, documented in
README-서버실행.md.
2026-10-09 16:43:35 +09:00

106 lines
6.2 KiB
JavaScript

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;
}