Die maxAge-Option von fastify-static setzt den Cache-Header fuer jede Datei, auch fuer die index.html. Der setHeaders-Callback, der das fuer HTML wieder aufheben sollte, kam dagegen nicht an: ausgeliefert wurde durchgehend public, max-age=31536000. Damit bekam ein Browser nach einem Update weiter die alte Oberflaeche - ein Jahr lang, ohne Fehlermeldung, obwohl der Server laengst neue Dateien hatte. Genau so ist der Abgleich-Knopf nach dem Deploy unsichtbar geblieben. Jetzt setzt die App beide Faelle selbst: HTML immer neu holen, Assets unbegrenzt behalten. Deren Dateinamen enthalten einen Inhalts-Hash, sie aendern sich bei neuem Inhalt ohnehin. Drei Tests pruefen die Header, weil ein Fehler an dieser Stelle nirgends auffaellt - er sieht aus, als waere das Update nicht angekommen.
163 lines
5.5 KiB
TypeScript
163 lines
5.5 KiB
TypeScript
import { existsSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import Fastify, { type FastifyInstance } from 'fastify';
|
|
import cookie from '@fastify/cookie';
|
|
import helmet from '@fastify/helmet';
|
|
import multipart from '@fastify/multipart';
|
|
import rateLimit from '@fastify/rate-limit';
|
|
import fastifyStatic from '@fastify/static';
|
|
import { Prisma } from '@prisma/client';
|
|
import { env } from './env.js';
|
|
import { AppError } from './errors.js';
|
|
import { attachUser, blockUntilPasswordChanged, enforceCsrf } from './auth/guard.js';
|
|
import { authRoutes } from './routes/auth.routes.js';
|
|
import { playerRoutes } from './routes/players.routes.js';
|
|
import { creditRoutes } from './routes/credits.routes.js';
|
|
import { characterRoutes } from './routes/characters.routes.js';
|
|
import { importRoutes } from './routes/imports.routes.js';
|
|
import { adminRoutes } from './routes/admin.routes.js';
|
|
import { scraperRoutes } from './routes/scraper.routes.js';
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
// Im Container liegt das gebaute Frontend neben dem kompilierten Server.
|
|
const webRoot = join(here, '..', 'public');
|
|
|
|
function readStatusCode(error: unknown): number | undefined {
|
|
const value = (error as { statusCode?: unknown }).statusCode;
|
|
return typeof value === 'number' ? value : undefined;
|
|
}
|
|
|
|
function readMessage(error: unknown): string | undefined {
|
|
const value = (error as { message?: unknown }).message;
|
|
return typeof value === 'string' ? value : undefined;
|
|
}
|
|
|
|
export async function buildApp(): Promise<FastifyInstance> {
|
|
const app = Fastify({
|
|
logger: {
|
|
level: env.LOG_LEVEL,
|
|
redact: ['req.headers.cookie', 'req.headers.authorization'],
|
|
},
|
|
trustProxy: env.TRUST_PROXY,
|
|
bodyLimit: 2 * 1024 * 1024,
|
|
});
|
|
|
|
await app.register(helmet, {
|
|
contentSecurityPolicy: {
|
|
directives: {
|
|
defaultSrc: ["'self'"],
|
|
scriptSrc: ["'self'"],
|
|
// Der QR-Code der 2FA-Einrichtung kommt als data-URL.
|
|
imgSrc: ["'self'", 'data:'],
|
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
connectSrc: ["'self'"],
|
|
objectSrc: ["'none'"],
|
|
baseUri: ["'self'"],
|
|
frameAncestors: ["'none'"],
|
|
formAction: ["'self'"],
|
|
},
|
|
},
|
|
crossOriginEmbedderPolicy: false,
|
|
hsts: env.isProduction ? { maxAge: 31536000, includeSubDomains: true } : false,
|
|
});
|
|
|
|
await app.register(cookie, { secret: env.SESSION_SECRET });
|
|
|
|
await app.register(rateLimit, {
|
|
global: true,
|
|
max: 300,
|
|
timeWindow: '1 minute',
|
|
allowList: () => !env.isProduction,
|
|
});
|
|
|
|
await app.register(multipart, {
|
|
limits: { fileSize: 10 * 1024 * 1024, files: 1 },
|
|
});
|
|
|
|
app.addHook('onRequest', attachUser);
|
|
app.addHook('onRequest', enforceCsrf);
|
|
app.addHook('preHandler', blockUntilPasswordChanged);
|
|
|
|
app.setErrorHandler((error, request, reply) => {
|
|
if (error instanceof AppError) {
|
|
reply.code(error.statusCode).send({ error: error.code, message: error.message });
|
|
return;
|
|
}
|
|
|
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
|
if (error.code === 'P2002') {
|
|
reply.code(409).send({ error: 'UNIQUE_VIOLATION', message: 'Dieser Wert ist bereits vergeben' });
|
|
return;
|
|
}
|
|
if (error.code === 'P2025') {
|
|
reply.code(404).send({ error: 'NOT_FOUND', message: 'Datensatz nicht gefunden' });
|
|
return;
|
|
}
|
|
}
|
|
|
|
const statusCode = readStatusCode(error);
|
|
|
|
if (statusCode === 429) {
|
|
reply.code(429).send({ error: 'RATE_LIMITED', message: 'Zu viele Anfragen, bitte kurz warten' });
|
|
return;
|
|
}
|
|
|
|
if (statusCode !== undefined && statusCode >= 400 && statusCode < 500) {
|
|
reply
|
|
.code(statusCode)
|
|
.send({ error: 'BAD_REQUEST', message: readMessage(error) ?? 'Ungueltige Anfrage' });
|
|
return;
|
|
}
|
|
|
|
request.log.error({ err: error }, 'Unbehandelter Fehler');
|
|
reply.code(500).send({ error: 'INTERNAL', message: 'Interner Fehler' });
|
|
});
|
|
|
|
app.get('/api/health', async () => ({ status: 'ok' }));
|
|
|
|
await app.register(authRoutes);
|
|
await app.register(playerRoutes);
|
|
await app.register(creditRoutes);
|
|
await app.register(characterRoutes);
|
|
await app.register(importRoutes);
|
|
await app.register(adminRoutes);
|
|
await app.register(scraperRoutes);
|
|
|
|
if (existsSync(webRoot)) {
|
|
await app.register(fastifyStatic, {
|
|
root: webRoot,
|
|
prefix: '/',
|
|
// maxAge wuerde den Header fuer *alle* Dateien setzen und dabei auch die
|
|
// index.html erwischen - die muss aber bei jedem Aufruf neu geholt werden,
|
|
// sonst zeigt der Browser nach einem Update monatelang die alte Oberflaeche.
|
|
// Deshalb hier abschalten und beides unten selbst setzen.
|
|
cacheControl: false,
|
|
setHeaders: (res, path) => {
|
|
if (path.endsWith('.html')) {
|
|
res.setHeader('Cache-Control', 'no-cache, must-revalidate');
|
|
} else {
|
|
// Assets tragen einen Inhalts-Hash im Namen: aendert sich der Inhalt,
|
|
// aendert sich der Dateiname. Die duerfen beliebig lange liegen.
|
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
// Alle nicht-API-Pfade gehen an die SPA, damit Deep-Links funktionieren.
|
|
app.setNotFoundHandler((request, reply) => {
|
|
if (request.url.startsWith('/api/')) {
|
|
reply.code(404).send({ error: 'NOT_FOUND', message: 'Unbekannter Endpunkt' });
|
|
return;
|
|
}
|
|
if (existsSync(webRoot)) {
|
|
reply.sendFile('index.html');
|
|
return;
|
|
}
|
|
reply.code(404).send({ error: 'NOT_FOUND', message: 'Frontend ist nicht gebaut' });
|
|
});
|
|
|
|
return app;
|
|
}
|