All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 38s
De echte, uiteindelijke oorzaak achter elke "serverfout" die niet door een route-eigen foutafhandeling kwam (klas verwijderen, en zeer waarschijnlijk ook de oorspronkelijke gebruiker-verwijderen-bug): de client stuurde bij élk verzoek altijd "Content-Type: application/json" mee, ook bij een DELETE zonder body. Fastify's standaard JSON-parser weigert zo'n leeg lichaam met "Body cannot be empty when content-type is set to 'application/json'" - vóórdat er ook maar één hook of route-handler heeft kunnen draaien, dus nog vóór req.user gezet wordt en vóór enige try/catch in een route. Dat verklaart waarom eerdere per-route foutafhandeling dit nooit kon vangen: de fout zat vóór elke route. - public/js/core.js: api() stuurt Content-Type alleen nog mee als er werkelijk een JSON-body is - src/body-parser.js (nieuw): serverside vangnet dat een leeg lichaam bij Content-Type: application/json gewoon toestaat i.p.v. te weigeren - ongeacht wat een client meestuurt - test/body-parser.test.js: reproduceert de exacte foutmelding tegen een echte Fastify-requestcyclus (de gemockte-pool-tests konden dit principieel nooit vangen, vandaar dat dit alle eerdere testrondes overleefde)
134 lines
4.8 KiB
JavaScript
134 lines
4.8 KiB
JavaScript
import { fileURLToPath } from 'node:url';
|
|
import { dirname, join } from 'node:path';
|
|
import zlib from 'node:zlib';
|
|
import Fastify from 'fastify';
|
|
import fastifyCookie from '@fastify/cookie';
|
|
import fastifyCompress from '@fastify/compress';
|
|
import fastifyHelmet from '@fastify/helmet';
|
|
import fastifyRateLimit from '@fastify/rate-limit';
|
|
import pg from 'pg';
|
|
import api, { bootstrapSuper } from './api.js';
|
|
import imageApi from './images.js';
|
|
import { registerFrontend, resolveAppVersion } from './frontend.js';
|
|
import { runMigrations } from './migrate.js';
|
|
import { registerEmptyJsonBodyParser } from './body-parser.js';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
|
const PORT = Number(process.env.PORT ?? 3000);
|
|
const HOST = process.env.HOST ?? '0.0.0.0';
|
|
const TRUST_PROXY_HOPS = Math.max(0, Number(process.env.TRUST_PROXY_HOPS ?? 2));
|
|
const IS_PRODUCTION = process.env.NODE_ENV === 'production';
|
|
|
|
const app = Fastify({
|
|
logger: true,
|
|
// Pangolin/Traefik en nginx vormen normaal twee vertrouwde proxy-hops.
|
|
// Vertrouw nooit willekeurige X-Forwarded-* headers van directe clients.
|
|
trustProxy: TRUST_PROXY_HOPS,
|
|
// Alleen de borddata-route krijgt expliciet een ruimere limiet.
|
|
bodyLimit: 1024 * 1024,
|
|
});
|
|
|
|
// --- Database pool -----------------------------------------------------------
|
|
// Verbinding via DATABASE_URL, bv: postgres://teach:pw@db:5432/teach
|
|
const pool = new pg.Pool({
|
|
connectionString: process.env.DATABASE_URL,
|
|
max: Number(process.env.PG_POOL_MAX ?? 10),
|
|
});
|
|
|
|
// Maak de pool bereikbaar in routes via app.pg
|
|
app.decorate('pg', pool);
|
|
|
|
registerEmptyJsonBodyParser(app);
|
|
|
|
// --- Health checks (gebruikt door Docker + load balancer) --------------------
|
|
app.get('/healthz', async () => ({ status: 'ok' }));
|
|
|
|
app.get('/readyz', async (req, reply) => {
|
|
try {
|
|
await pool.query('SELECT 1');
|
|
return { status: 'ready' };
|
|
} catch (err) {
|
|
req.log.error({ err }, 'database not ready');
|
|
reply.code(503);
|
|
return { status: 'db_unavailable' };
|
|
}
|
|
});
|
|
|
|
// --- HTTP-beveiliging --------------------------------------------------------
|
|
await app.register(fastifyCookie);
|
|
await app.register(fastifyRateLimit, { global: false });
|
|
await app.register(fastifyHelmet, {
|
|
global: true,
|
|
hsts: IS_PRODUCTION ? { maxAge: 31536000, includeSubDomains: true } : false,
|
|
contentSecurityPolicy: {
|
|
directives: {
|
|
defaultSrc: ["'self'"],
|
|
scriptSrc: ["'self'"],
|
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
imgSrc: ["'self'", 'data:', 'blob:', 'https:'],
|
|
mediaSrc: ["'self'", 'blob:', 'https:'],
|
|
connectSrc: ["'self'", 'https://api.arasaac.org'],
|
|
frameSrc: ['https://www.youtube.com', 'https://player.vimeo.com'],
|
|
objectSrc: ["'none'"],
|
|
baseUri: ["'self'"],
|
|
formAction: ["'self'"],
|
|
frameAncestors: ["'none'"],
|
|
upgradeInsecureRequests: IS_PRODUCTION ? [] : null,
|
|
},
|
|
},
|
|
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
|
});
|
|
app.addHook('onSend', async (_req, reply) => {
|
|
reply.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=(), fullscreen=(self)');
|
|
});
|
|
|
|
// --- Compressie ---------------------------------------------------------------
|
|
// Vóór de API- en frontend-registraties: de onSend-hook van compress geldt
|
|
// alleen voor routes die daarna worden toegevoegd. Brotli op quality 4:
|
|
// vergelijkbare ratio als gzip-9 tegen een fractie van de CPU (belangrijk voor
|
|
// grote dynamische antwoorden zoals /me/data).
|
|
await app.register(fastifyCompress, {
|
|
global: true,
|
|
encodings: ['br', 'gzip'],
|
|
threshold: 1024,
|
|
brotliOptions: { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 4 } },
|
|
zlibOptions: { level: 6 },
|
|
});
|
|
|
|
// --- API ---------------------------------------------------------------------
|
|
// Inloggen, rollen, gebruikersbeheer en per-gebruiker data. Zie src/api.js.
|
|
app.register(api, { prefix: '/api' });
|
|
app.register(imageApi, { prefix: '/api' });
|
|
|
|
// --- Static frontend ---------------------------------------------------------
|
|
// index.html met versie-injectie (cache-busting) + cache-headers per prefix;
|
|
// zie src/frontend.js voor het beleid.
|
|
await registerFrontend(app, {
|
|
root: join(__dirname, '..', 'public'),
|
|
version: resolveAppVersion(),
|
|
});
|
|
|
|
// --- Start -------------------------------------------------------------------
|
|
const start = async () => {
|
|
try {
|
|
await runMigrations(pool, app.log, join(__dirname, '..', 'db'));
|
|
await bootstrapSuper(pool, app.log);
|
|
await app.listen({ port: PORT, host: HOST });
|
|
} catch (err) {
|
|
app.log.error(err);
|
|
process.exit(1);
|
|
}
|
|
};
|
|
|
|
// Nette shutdown zodat Docker de container snel kan stoppen
|
|
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
process.on(signal, async () => {
|
|
app.log.info(`${signal} ontvangen, afsluiten...`);
|
|
await app.close();
|
|
await pool.end();
|
|
process.exit(0);
|
|
});
|
|
}
|
|
|
|
start();
|