126 lines
6.2 KiB
JavaScript
126 lines
6.2 KiB
JavaScript
import express from "express";
|
|
import cors from "cors";
|
|
import helmet from "helmet";
|
|
import rateLimit from "express-rate-limit";
|
|
import cookieParser from "cookie-parser";
|
|
import pinoHttp from "pino-http";
|
|
import { existsSync } from "fs";
|
|
import { extname, join, relative, sep } from "path";
|
|
import { config } from "./config/index.js";
|
|
import { logger } from "./logger.js";
|
|
import { initDb } from "./db.js";
|
|
import authRouter from "./routes/auth.js";
|
|
import dataRouter from "./routes/data.js";
|
|
import usersRouter from "./routes/users.js";
|
|
import healthRouter from "./routes/health.js";
|
|
import updateRouter from "./routes/update.js";
|
|
import { errorHandler } from "./middleware/errorHandler.js";
|
|
|
|
export { logger };
|
|
|
|
const app = express();
|
|
|
|
// ── Security ──────────────────────────────────────────────────────────────────
|
|
// De app serveert nu ook de frontend, dus het CSP-beleid moet passen bij wat
|
|
// die pagina laadt: Google Fonts, inline <style> van React en base64-avatars.
|
|
app.use(helmet({
|
|
contentSecurityPolicy: {
|
|
directives: {
|
|
defaultSrc: ["'self'"],
|
|
scriptSrc: ["'self'"],
|
|
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
|
|
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
|
|
imgSrc: ["'self'", "data:", "blob:"],
|
|
connectSrc: ["'self'"],
|
|
objectSrc: ["'none'"],
|
|
baseUri: ["'self'"],
|
|
formAction: ["'self'"],
|
|
// Uit: intern draait de app op http achter een reverse proxy.
|
|
upgradeInsecureRequests: null,
|
|
},
|
|
},
|
|
}));
|
|
app.use(cors({ origin: config.CORS_ORIGIN, credentials: true }));
|
|
app.set("trust proxy", 1);
|
|
|
|
// ── Rate limiting (alleen op de API — niet op statische bestanden) ────────────
|
|
app.use("/api/auth", rateLimit({ windowMs: 15 * 60 * 1000, max: 20, standardHeaders: true, legacyHeaders: false }));
|
|
app.use("/api", rateLimit({ windowMs: 15 * 60 * 1000, max: 200, standardHeaders: true, legacyHeaders: false }));
|
|
|
|
// ── Parsing ───────────────────────────────────────────────────────────────────
|
|
app.use(express.json({ limit: "2mb" }));
|
|
app.use(cookieParser());
|
|
|
|
// ── Logging ───────────────────────────────────────────────────────────────────
|
|
if (config.NODE_ENV !== "test") {
|
|
app.use(pinoHttp({ logger }));
|
|
}
|
|
|
|
// ── API-routes ────────────────────────────────────────────────────────────────
|
|
app.use("/api/health", healthRouter);
|
|
app.use("/api/update-check", updateRouter);
|
|
app.use("/api/auth", authRouter);
|
|
app.use("/api/data", dataRouter);
|
|
app.use("/api/users", usersRouter);
|
|
|
|
// ── 404 voor overige /api/* ───────────────────────────────────────────────────
|
|
// Zonder pad-patroon met wildcard: Express 5 vereist benoemde wildcards
|
|
// (/api/*splat), en een prefix-mount doet hier precies hetzelfde.
|
|
app.use("/api", (_req, res) => res.status(404).json({ error: "Endpoint niet gevonden." }));
|
|
|
|
// ── Frontend (Vite build) ─────────────────────────────────────────────────────
|
|
const indexHtml = join(config.STATIC_DIR, "index.html");
|
|
export const servesFrontend = existsSync(indexHtml);
|
|
|
|
if (servesFrontend) {
|
|
// Alleen de bundels in /assets krijgen bij elke build een nieuwe, gehashte
|
|
// naam — die mogen een jaar blijven staan. Bestanden met een vaste naam
|
|
// (favicon.svg, icons/*) moeten revalideren: met "immutable" blijft een
|
|
// eenmaal verkeerd gecacht antwoord een jaar hangen, ook na een reload.
|
|
app.use(express.static(config.STATIC_DIR, {
|
|
index: false,
|
|
setHeaders(res, bestandspad) {
|
|
const relatief = relative(config.STATIC_DIR, bestandspad).split(sep).join("/");
|
|
if (bestandspad.endsWith(".html")) {
|
|
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
|
|
} else if (relatief.startsWith("assets/")) {
|
|
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
|
} else {
|
|
res.setHeader("Cache-Control", "public, max-age=0, must-revalidate");
|
|
}
|
|
},
|
|
}));
|
|
|
|
// SPA-fallback: alle overige GET-routes naar index.html. Pathloze middleware
|
|
// in plaats van app.get("*") — dat patroon is in Express 5 ongeldig.
|
|
//
|
|
// Paden met een extensie vragen om een bestand (/favicon.ico, /robots.txt)
|
|
// en krijgen een echte 404. Anders levert zo'n verzoek index.html op met
|
|
// status 200 en content-type text/html: de browser krijgt HTML waar hij een
|
|
// icoon verwacht en toont dan niets, zonder zichtbare fout.
|
|
app.use((req, res, next) => {
|
|
if (req.method !== "GET" && req.method !== "HEAD") return next();
|
|
if (extname(req.path)) return next();
|
|
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
|
|
res.sendFile(indexHtml);
|
|
});
|
|
} else {
|
|
logger.warn({ dir: config.STATIC_DIR }, "Geen frontend-build gevonden — alleen de API is beschikbaar");
|
|
}
|
|
|
|
// ── Error handler ─────────────────────────────────────────────────────────────
|
|
app.use(errorHandler);
|
|
|
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
if (config.NODE_ENV !== "test") {
|
|
await initDb();
|
|
app.listen(config.PORT, () => {
|
|
logger.info(
|
|
`VibeFinance gestart op http://localhost:${config.PORT} [${config.NODE_ENV}]` +
|
|
(servesFrontend ? " — API + frontend" : " — alleen API")
|
|
);
|
|
});
|
|
}
|
|
|
|
export default app;
|