Migratie naar 2-container opzet (app + database)
De Express-app serveert nu zelf de gebouwde Vite-frontend op / met een SPA-fallback naar index.html; de aparte nginx-container is vervallen. Bedoeld voor self-hosting op een Raspberry Pi: één image, één origin, geen interne proxy en geen CORS tussen frontend en API. Docker - Nieuwe multi-stage Dockerfile in de root met targets test, development en production; frontend/Dockerfile, frontend/nginx-spa.conf en backend/Dockerfile verwijderd - docker-compose.yml: alleen app + postgres, app publiceert APP_PORT (standaard 3400) naar containerpoort 3001 - docker-compose.dev.yml: dev-container draait API (node --watch) en de Vite dev-server samen via docker/dev-entrypoint.sh, hot reload intact - .dockerignore, .env.example, .gitignore en .gitattributes toegevoegd Backend - express.static + SPA-fallback op STATIC_DIR (default /app/public), met immutable caching voor gehashte assets en no-store voor index.html - Rate limiting alleen nog op /api — statische bestanden liepen anders na 200 requests tegen de limiet aan - Helmet CSP toegespitst op wat de frontend laadt (Google Fonts, inline styles, base64-avatars); upgrade-insecure-requests uit voor intern http - Schema-init verplaatst naar initDb(), niet meer bij import van db.js Tests en CI/CD - backend/test/app.test.js: 11 tests (node --test) voor API-routes, SPA-fallback, cache- en security-headers, zonder database - test.ps1 / test.sh: backend-tests, productiebuild en smoketest op de echte app + postgres stack - azure-pipelines.yml: Test -> Build (incl. smoketest) -> Publish (multi-arch amd64 + arm64, alleen op main/master of een tag) Scripts en documentatie - dev.ps1/dev.sh met targets app en postgres, acties logs en test; dev.ps1 up wist de dev-database niet langer (alleen dev.ps1 down) - release.ps1/release.sh bouwen één image voor amd64 + arm64 en draaien de testsuite vóór het publiceren - README.md en BEHEER.md bijgewerkt voor de nieuwe structuur Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3c67381de9
commit
bfec3a25f3
@@ -1,4 +1,8 @@
|
||||
import "dotenv/config";
|
||||
import { dirname, join, resolve } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export const config = {
|
||||
NODE_ENV: process.env.NODE_ENV || "development",
|
||||
@@ -9,6 +13,12 @@ export const config = {
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
REDIS_URL: process.env.REDIS_URL,
|
||||
|
||||
// Map met de gebouwde frontend (Vite dist). In het productie-image staat die
|
||||
// op /app/public; bestaat de map niet, dan serveert de app alleen de API.
|
||||
STATIC_DIR: process.env.STATIC_DIR
|
||||
? resolve(process.env.STATIC_DIR)
|
||||
: join(__dirname, "..", "..", "public"),
|
||||
|
||||
// Update-notificaties: welke release-API wordt gepolld (Gitea).
|
||||
// Leeg laten schakelt de melding in de app uit.
|
||||
UPDATE_CHECK_URL: process.env.UPDATE_CHECK_URL
|
||||
|
||||
+28
-21
@@ -7,27 +7,34 @@ export const db = new Pool({
|
||||
connectionString: config.DATABASE_URL,
|
||||
});
|
||||
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
naam TEXT NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
wachtwoord TEXT NOT NULL,
|
||||
rol TEXT NOT NULL DEFAULT 'Viewer',
|
||||
actief BOOLEAN NOT NULL DEFAULT true,
|
||||
aangemaakt TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
/**
|
||||
* Zet het schema klaar. Wordt bij het starten van de server aangeroepen —
|
||||
* niet bij het importeren, zodat de app ook zonder database te importeren is
|
||||
* (tests, health checks tijdens de build).
|
||||
*/
|
||||
export async function initDb() {
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
naam TEXT NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
wachtwoord TEXT NOT NULL,
|
||||
rol TEXT NOT NULL DEFAULT 'Viewer',
|
||||
actief BOOLEAN NOT NULL DEFAULT true,
|
||||
aangemaakt TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_data (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
CREATE TABLE IF NOT EXISTS user_data (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS aangemaakt TIMESTAMPTZ NOT NULL DEFAULT NOW();
|
||||
`);
|
||||
await db.query(`
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS aangemaakt TIMESTAMPTZ NOT NULL DEFAULT NOW();
|
||||
`);
|
||||
|
||||
await db.query(`
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar TEXT;
|
||||
`);
|
||||
await db.query(`
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar TEXT;
|
||||
`);
|
||||
}
|
||||
|
||||
+61
-16
@@ -3,9 +3,12 @@ import cors from "cors";
|
||||
import helmet from "helmet";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import cookieParser from "cookie-parser";
|
||||
import pino from "pino";
|
||||
import pinoHttp from "pino-http";
|
||||
import { existsSync } from "fs";
|
||||
import { join } 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";
|
||||
@@ -13,23 +16,36 @@ import healthRouter from "./routes/health.js";
|
||||
import updateRouter from "./routes/update.js";
|
||||
import { errorHandler } from "./middleware/errorHandler.js";
|
||||
|
||||
export const logger = pino({
|
||||
level: config.NODE_ENV === "production" ? "info" : "debug",
|
||||
...(config.NODE_ENV !== "production" && {
|
||||
transport: { target: "pino-pretty", options: { colorize: true } },
|
||||
}),
|
||||
});
|
||||
export { logger };
|
||||
|
||||
const app = express();
|
||||
|
||||
// ── Security ──────────────────────────────────────────────────────────────────
|
||||
app.use(helmet());
|
||||
// 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 ─────────────────────────────────────────────────────────────
|
||||
// ── 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(rateLimit({ windowMs: 15 * 60 * 1000, max: 200, 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" }));
|
||||
@@ -40,23 +56,52 @@ if (config.NODE_ENV !== "test") {
|
||||
app.use(pinoHttp({ logger }));
|
||||
}
|
||||
|
||||
// ── Routes ────────────────────────────────────────────────────────────────────
|
||||
app.use("/api/health", healthRouter);
|
||||
// ── 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);
|
||||
app.use("/api/auth", authRouter);
|
||||
app.use("/api/data", dataRouter);
|
||||
app.use("/api/users", usersRouter);
|
||||
|
||||
// ── 404 voor overige /api/* ───────────────────────────────────────────────────
|
||||
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) {
|
||||
// Assets hebben een hash in de naam en mogen lang gecached worden;
|
||||
// index.html juist nooit, anders blijft een oude build hangen.
|
||||
app.use(express.static(config.STATIC_DIR, {
|
||||
index: false,
|
||||
setHeaders(res, path) {
|
||||
res.setHeader("Cache-Control", path.endsWith(".html")
|
||||
? "no-store, no-cache, must-revalidate"
|
||||
: "public, max-age=31536000, immutable");
|
||||
},
|
||||
}));
|
||||
|
||||
// SPA-fallback: alle overige GET-routes naar index.html.
|
||||
app.get("*", (_req, res) => {
|
||||
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 API gestart op http://localhost:${config.PORT} [${config.NODE_ENV}]`);
|
||||
logger.info(
|
||||
`VibeFinance gestart op http://localhost:${config.PORT} [${config.NODE_ENV}]` +
|
||||
(servesFrontend ? " — API + frontend" : " — alleen API")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user