From b2a4bfa53781761dd508a7fdf40d46ac699dc72d Mon Sep 17 00:00:00 2001 From: Hans-Christian Payer Date: Wed, 20 May 2026 09:24:11 +0200 Subject: [PATCH] initial commit --- README.md | 0 serienbrief-backend.tar.gz | Bin 0 -> 14691 bytes serienbrief-backend/.dockerignore | 7 + serienbrief-backend/.env.example | 40 + serienbrief-backend/.gitignore | 5 + serienbrief-backend/Dockerfile | 46 + serienbrief-backend/README.md | 93 ++ serienbrief-backend/package.json | 28 + serienbrief-backend/src/config/index.js | 77 ++ serienbrief-backend/src/middleware/auth.js | 65 ++ .../src/middleware/errorHandler.js | 29 + serienbrief-backend/src/routes/audit.js | 33 + serienbrief-backend/src/routes/recipients.js | 58 ++ serienbrief-backend/src/routes/render.js | 141 +++ serienbrief-backend/src/routes/templates.js | 38 + serienbrief-backend/src/server.js | 143 +++ serienbrief-backend/src/services/auditLog.js | 156 ++++ serienbrief-backend/src/services/directus.js | 110 +++ serienbrief-backend/src/services/logger.js | 19 + .../src/services/pdfRenderer.js | 143 +++ .../src/services/templateCache.js | 67 ++ serienbrief_konzept_directus.md | 852 ++++++++++++++++++ 22 files changed, 2150 insertions(+) create mode 100644 README.md create mode 100644 serienbrief-backend.tar.gz create mode 100644 serienbrief-backend/.dockerignore create mode 100644 serienbrief-backend/.env.example create mode 100644 serienbrief-backend/.gitignore create mode 100644 serienbrief-backend/Dockerfile create mode 100644 serienbrief-backend/README.md create mode 100644 serienbrief-backend/package.json create mode 100644 serienbrief-backend/src/config/index.js create mode 100644 serienbrief-backend/src/middleware/auth.js create mode 100644 serienbrief-backend/src/middleware/errorHandler.js create mode 100644 serienbrief-backend/src/routes/audit.js create mode 100644 serienbrief-backend/src/routes/recipients.js create mode 100644 serienbrief-backend/src/routes/render.js create mode 100644 serienbrief-backend/src/routes/templates.js create mode 100644 serienbrief-backend/src/server.js create mode 100644 serienbrief-backend/src/services/auditLog.js create mode 100644 serienbrief-backend/src/services/directus.js create mode 100644 serienbrief-backend/src/services/logger.js create mode 100644 serienbrief-backend/src/services/pdfRenderer.js create mode 100644 serienbrief-backend/src/services/templateCache.js create mode 100644 serienbrief_konzept_directus.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/serienbrief-backend.tar.gz b/serienbrief-backend.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..3811a8a117e4e5c4a5e45ebc09e5913f1437558b GIT binary patch literal 14691 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 literal 0 HcmV?d00001 diff --git a/serienbrief-backend/.dockerignore b/serienbrief-backend/.dockerignore new file mode 100644 index 0000000..cd4cc8e --- /dev/null +++ b/serienbrief-backend/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.git +.gitignore +*.md +secrets/ +templates/ +.env* diff --git a/serienbrief-backend/.env.example b/serienbrief-backend/.env.example new file mode 100644 index 0000000..4a9e5b1 --- /dev/null +++ b/serienbrief-backend/.env.example @@ -0,0 +1,40 @@ +# ── Serienbrief-Backend Konfiguration ───────────────────────────────────────── +# Kopieren nach .env und anpassen. .env NIE ins Git-Repository einchecken! + +# Server +PORT=3001 +NODE_ENV=production +LOG_LEVEL=info + +# Directus (Service-Account) +DIRECTUS_URL=http://directus:8055 +# DIRECTUS_TOKEN=direkt-als-env-variable (nur für Entwicklung) +# In Produktion: DIRECTUS_TOKEN_FILE=/run/secrets/directus_token + +# PDF-Renderer (gotenberg | carbone) +RENDERER=gotenberg +PDF_RENDERER_URL=http://gotenberg:3000 +PDF_RENDERER_TIMEOUT_MS=30000 + +# Template-Cache TTL (5 Minuten) +TEMPLATE_CACHE_TTL_MS=300000 +TEMPLATE_STORE_PATH=/templates + +# Empfänger-Limit pro Request +MAX_RECIPIENTS=200 + +# Audit-Datenbank +AUDIT_DB_HOST=sb-audit-db +AUDIT_DB_PORT=5432 +AUDIT_DB_NAME=sb_audit +AUDIT_DB_USER=sb_audit_user +# AUDIT_DB_PASSWORD=direkt (nur für Entwicklung) +# In Produktion: AUDIT_DB_PASSWORD_FILE=/run/secrets/db_audit_password +AUDIT_DB_SSL=false + +# Directus-Rollen-UUIDs mit Admin-Rechten auf /api/sb/audit (kommagetrennt) +# ADMIN_ROLE_IDS=uuid1,uuid2 + +# Rate-Limiting +RATE_LIMIT_WINDOW_MS=60000 +RATE_LIMIT_MAX=20 diff --git a/serienbrief-backend/.gitignore b/serienbrief-backend/.gitignore new file mode 100644 index 0000000..9fb536a --- /dev/null +++ b/serienbrief-backend/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.env +secrets/ +*.log +dist/ diff --git a/serienbrief-backend/Dockerfile b/serienbrief-backend/Dockerfile new file mode 100644 index 0000000..669a8cc --- /dev/null +++ b/serienbrief-backend/Dockerfile @@ -0,0 +1,46 @@ +# ── Build-Stage ─────────────────────────────────────────────────────────────── +# Abhängigkeiten separat installieren, damit der finale Layer schlanker bleibt. +FROM node:22-alpine AS deps + +WORKDIR /app +COPY package.json package-lock.json* ./ + +# Nur Produktions-Abhängigkeiten; kein devDependencies +RUN npm ci --omit=dev && npm cache clean --force + +# ── Runtime-Stage ───────────────────────────────────────────────────────────── +FROM node:22-alpine AS runtime + +# Metadaten +LABEL org.opencontainers.image.title="serienbrief-backend" +LABEL org.opencontainers.image.description="Serienbrief-Backend für Directus + Gotenberg" +LABEL org.opencontainers.image.licenses="MIT" + +# LibreOffice nur wenn RENDERER=carbone (Gotenberg bringt eigenes LibreOffice mit) +# ARG INSTALL_LIBREOFFICE=false +# RUN if [ "$INSTALL_LIBREOFFICE" = "true" ]; then apk add --no-cache libreoffice font-freefont; fi + +# Sicherheit: kein root +RUN addgroup -g 1001 sbapp && adduser -u 1001 -G sbapp -s /bin/sh -D sbapp + +WORKDIR /app + +# Nur nötige Dateien kopieren +COPY --from=deps /app/node_modules ./node_modules +COPY --chown=sbapp:sbapp src/ ./src/ +COPY --chown=sbapp:sbapp package.json ./ + +# Verzeichnisse für Templates und Secrets (werden als Volumes gemountet) +RUN mkdir -p /templates /run/secrets && chown sbapp:sbapp /templates + +USER sbapp + +# Kein EXPOSE nötig (wird im Compose-File definiert) +# Port dokumentarisch: +EXPOSE 3001 + +# Healthcheck (auch ohne externen curl/wget über Node) +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD node -e "fetch('http://localhost:3001/health').then(r=>r.ok?process.exit(0):process.exit(1)).catch(()=>process.exit(1))" + +CMD ["node", "src/server.js"] diff --git a/serienbrief-backend/README.md b/serienbrief-backend/README.md new file mode 100644 index 0000000..f2e20f9 --- /dev/null +++ b/serienbrief-backend/README.md @@ -0,0 +1,93 @@ +# Serienbrief-Backend + +Node.js/Express-Backend für das Serienbrief-System auf Basis von **Directus** (Adressdatenbank) und **Gotenberg/Carbone** (PDF-Rendering). + +## Voraussetzungen + +- Docker Engine 27.x, Docker Compose V2 +- Laufende Directus-Instanz mit: + - Collection `sb_templates` (siehe Konzept-Dokument) + - Service-Account-Token mit Lesezugriff auf Kontakt-Collections und `sb_templates` +- Gotenberg v8 (oder Carbone, via `RENDERER=carbone`) +- PostgreSQL-Instanz für Audit-Log + +## Schnellstart (Entwicklung) + +```bash +cp .env.example .env +# .env anpassen (DIRECTUS_TOKEN, AUDIT_DB_* etc.) + +npm install +npm run dev +``` + +## Umgebungsvariablen + +Alle Optionen: siehe `.env.example` + +## API-Endpunkte + +| Methode | Pfad | Auth | Beschreibung | +|---|---|---|---| +| `GET` | `/health` | nein | Healthcheck | +| `GET` | `/api/sb/templates` | Bearer | Aktive Templates | +| `GET` | `/api/sb/recipients` | Bearer | Empfängerliste | +| `POST` | `/api/sb/render` | Bearer | PDF generieren | +| `GET` | `/api/sb/audit` | Bearer (Admin) | Audit-Log | +| `POST` | `/admin/reload-templates` | intern | Template-Cache leeren | + +## Render-Request + +```json +POST /api/sb/render +Authorization: Bearer + +{ + "templateId": "uuid-aus-sb_templates", + "collection": "kontakte", + "recipientIds": [42, 87, 133], + "extraFields": { "betreff": "Wichtige Information" } +} +``` + +Response: `application/pdf` (Datei-Download) + +## Template-Syntax (Carbone) + +Templates sind ODT- oder DOCX-Dateien mit Carbone-Platzhaltern: + +``` +{d.empfaenger[i].vorname} {d.empfaenger[i].nachname} +{d.empfaenger[i].adresse} +{d.empfaenger[i].plz} {d.empfaenger[i].ort} + +Datum: {d.meta.datum} +Betreff: {d.meta.betreff} +``` + +## Sicherheit + +- Service-Account-Token wird als Docker Secret geliefert (`/run/secrets/directus_token`) +- User-Bearer-Token wird gegen Directus `/users/me` validiert +- Generierte PDFs werden nur im RAM gehalten und direkt gestreamt (keine Persistenz) +- Audit-Log: INSERT-only (DB-User hat kein UPDATE/DELETE) +- Container läuft als non-root (UID 1001), `read_only: true`, `cap_drop: ALL` + +## Renderer-Auswahl + +| Variable | Wert | Beschreibung | +|---|---|---| +| `RENDERER` | `gotenberg` (default) | Carbone merged Template + Daten → Gotenberg konvertiert nach PDF | +| `RENDERER` | `carbone` | Carbone merged + konvertiert direkt (LibreOffice im Container nötig) | + +## Direktus-Collection `sb_templates` + +| Feld | Typ | Beschreibung | +|---|---|---| +| `id` | UUID | Primärschlüssel | +| `name` | String | Anzeigename | +| `description` | String | Beschreibung | +| `version` | String | z.B. `2.1` | +| `file_id` | FK → directus_files | Template-Datei | +| `active` | Boolean | Nur aktive werden angezeigt | +| `allowed_fields` | String | Kommagetrennte Felder-Allowlist | diff --git a/serienbrief-backend/package.json b/serienbrief-backend/package.json new file mode 100644 index 0000000..bef01c2 --- /dev/null +++ b/serienbrief-backend/package.json @@ -0,0 +1,28 @@ +{ + "name": "serienbrief-backend", + "version": "1.0.0", + "description": "Serienbrief-Backend: Directus + Carbone/Gotenberg PDF-Renderer", + "main": "src/server.js", + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "start": "node src/server.js", + "dev": "node --watch src/server.js", + "lint": "eslint src/" + }, + "dependencies": { + "carbone": "^4.23.2", + "express": "^4.19.2", + "express-rate-limit": "^7.3.1", + "form-data": "^4.0.0", + "helmet": "^7.1.0", + "node-fetch": "^3.3.2", + "pg": "^8.11.5", + "pino": "^9.2.0", + "pino-http": "^10.2.0" + }, + "devDependencies": { + "eslint": "^9.5.0" + } +} diff --git a/serienbrief-backend/src/config/index.js b/serienbrief-backend/src/config/index.js new file mode 100644 index 0000000..d7a51f4 --- /dev/null +++ b/serienbrief-backend/src/config/index.js @@ -0,0 +1,77 @@ +'use strict'; + +const fs = require('fs'); + +/** + * Liest einen Secret-Wert aus einer Datei (Docker Secrets Pattern) + * oder fällt auf eine Umgebungsvariable zurück. + */ +function readSecret(fileEnvKey, fallbackEnvKey) { + const filePath = process.env[fileEnvKey]; + if (filePath) { + try { + return fs.readFileSync(filePath, 'utf8').trim(); + } catch (err) { + throw new Error(`Secret-Datei nicht lesbar (${fileEnvKey}=${filePath}): ${err.message}`); + } + } + const val = process.env[fallbackEnvKey]; + if (!val) { + throw new Error(`Weder ${fileEnvKey} noch ${fallbackEnvKey} gesetzt.`); + } + return val; +} + +const config = { + server: { + port: parseInt(process.env.PORT || '3001', 10), + nodeEnv: process.env.NODE_ENV || 'development', + }, + + directus: { + url: process.env.DIRECTUS_URL || 'http://directus:8055', + // Service-Account-Token (statisch, kein User-Login) + get token() { + return readSecret('DIRECTUS_TOKEN_FILE', 'DIRECTUS_TOKEN'); + }, + // Maximale Anzahl Empfänger pro Render-Request (Schutz vor Ressourcen-Erschöpfung) + maxRecipients: parseInt(process.env.MAX_RECIPIENTS || '200', 10), + }, + + pdfRenderer: { + // Gotenberg REST-Endpunkt (intern) + url: process.env.PDF_RENDERER_URL || 'http://gotenberg:3000', + // Timeout in ms für PDF-Konvertierung + timeoutMs: parseInt(process.env.PDF_RENDERER_TIMEOUT_MS || '30000', 10), + }, + + templates: { + // Lokaler Bind-Mount; alternativ werden Templates aus Directus geladen + localPath: process.env.TEMPLATE_STORE_PATH || '/templates', + // TTL für den In-Memory-Cache (ms) + cacheTtlMs: parseInt(process.env.TEMPLATE_CACHE_TTL_MS || '300000', 10), + }, + + audit: { + host: process.env.AUDIT_DB_HOST || 'sb-audit-db', + port: parseInt(process.env.AUDIT_DB_PORT || '5432', 10), + database: process.env.AUDIT_DB_NAME || 'sb_audit', + user: process.env.AUDIT_DB_USER || 'sb_audit_user', + get password() { + return readSecret('AUDIT_DB_PASSWORD_FILE', 'AUDIT_DB_PASSWORD'); + }, + ssl: process.env.AUDIT_DB_SSL === 'true', + }, + + rateLimit: { + // Render-Endpunkt: max. Requests pro Zeitfenster + windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '60000', 10), + max: parseInt(process.env.RATE_LIMIT_MAX || '20', 10), + }, + + log: { + level: process.env.LOG_LEVEL || 'info', + }, +}; + +module.exports = config; diff --git a/serienbrief-backend/src/middleware/auth.js b/serienbrief-backend/src/middleware/auth.js new file mode 100644 index 0000000..37904af --- /dev/null +++ b/serienbrief-backend/src/middleware/auth.js @@ -0,0 +1,65 @@ +'use strict'; + +/** + * Auth-Middleware + * + * Validiert das Bearer-Token des eingehenden Requests gegen die Directus API. + * Der Nutzer muss sich mit einem Directus-User-Token (nicht dem Service-Token) + * authentifizieren — so bleibt die Identität für den Audit-Log erhalten. + * + * Empfohlenes Flow: + * Browser → POST /api/sb/auth/login (leitet an Directus weiter) + * → erhält { access_token, expires, refresh_token } + * Browser → alle weiteren Requests mit Authorization: Bearer + * + * Alternative: Direktes Weiterleiten des Directus-Tokens (simpler für interne Tools). + */ + +const config = require('../config'); +const logger = require('../services/logger'); + +/** + * Validiert ein Directus-Bearer-Token und hängt die User-Infos an req.user. + */ +async function authenticate(req, res, next) { + const authHeader = req.headers['authorization']; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Kein Bearer-Token vorhanden.' }); + } + + const token = authHeader.slice(7); + + // Service-Token darf nicht als User-Token verwendet werden + if (token === config.directus.token) { + logger.warn({ ip: req.ip }, 'Service-Token als User-Token versucht – abgewiesen'); + return res.status(403).json({ error: 'Service-Token nicht als User-Token erlaubt.' }); + } + + try { + const res2 = await fetch(`${config.directus.url}/users/me`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!res2.ok) { + return res.status(401).json({ error: 'Token ungültig oder abgelaufen.' }); + } + + const { data } = await res2.json(); + + // Minimale User-Infos für Audit-Log und Weiterverarbeitung + req.user = { + id: data.id, + email: data.email, + role: data.role, + token, // Für ggf. nachgelagerte Directus-Calls als User + }; + + next(); + } catch (err) { + logger.error({ err }, 'Auth-Validierung fehlgeschlagen'); + res.status(503).json({ error: 'Auth-Service nicht erreichbar.' }); + } +} + +module.exports = { authenticate }; diff --git a/serienbrief-backend/src/middleware/errorHandler.js b/serienbrief-backend/src/middleware/errorHandler.js new file mode 100644 index 0000000..64561dd --- /dev/null +++ b/serienbrief-backend/src/middleware/errorHandler.js @@ -0,0 +1,29 @@ +'use strict'; + +const logger = require('../services/logger'); + +/** + * Zentraler Express-Fehlerhandler. + * Sanitisiert Fehlerdetails in Produktion (kein Stack-Trace nach außen). + */ +function errorHandler(err, req, res, next) { // eslint-disable-line no-unused-vars + const status = err.status || err.statusCode || 500; + const isProduction = process.env.NODE_ENV === 'production'; + + logger.error({ + err, + method: req.method, + url: req.url, + status, + userId: req.user?.id, + }, 'Unbehandelter Fehler'); + + res.status(status).json({ + error: isProduction + ? (status < 500 ? err.message : 'Interner Serverfehler.') + : err.message, + ...(isProduction ? {} : { stack: err.stack }), + }); +} + +module.exports = { errorHandler }; diff --git a/serienbrief-backend/src/routes/audit.js b/serienbrief-backend/src/routes/audit.js new file mode 100644 index 0000000..8cc6537 --- /dev/null +++ b/serienbrief-backend/src/routes/audit.js @@ -0,0 +1,33 @@ +'use strict'; + +/** + * GET /api/sb/audit + * + * Gibt die letzten 50 Audit-Log-Einträge zurück. + * Nur für Nutzer mit der Directus-Rolle 'sb_admin' zugänglich. + */ + +const express = require('express'); +const auditLog = require('../services/auditLog'); +const { authenticate } = require('../middleware/auth'); + +const router = express.Router(); + +const ADMIN_ROLES = (process.env.ADMIN_ROLE_IDS || '').split(',').filter(Boolean); + +router.get('/', authenticate, async (req, res, next) => { + // Rollen-Check: req.user.role ist die Directus-Rollen-UUID + if (ADMIN_ROLES.length > 0 && !ADMIN_ROLES.includes(req.user.role)) { + return res.status(403).json({ error: 'Nur Administratoren können das Audit-Log einsehen.' }); + } + + try { + const limit = Math.min(parseInt(req.query.limit || '50', 10), 500); + const entries = await auditLog.getRecent(limit); + res.json({ data: entries }); + } catch (err) { + next(err); + } +}); + +module.exports = router; diff --git a/serienbrief-backend/src/routes/recipients.js b/serienbrief-backend/src/routes/recipients.js new file mode 100644 index 0000000..6176091 --- /dev/null +++ b/serienbrief-backend/src/routes/recipients.js @@ -0,0 +1,58 @@ +'use strict'; + +/** + * GET /api/sb/recipients + * + * Liefert eine gefilterte Empfängerliste für die Frontend-Auswahl. + * Felder sind auf Minimal-Set beschränkt (kein Freitext-Feldaufruf). + * + * Query-Parameter: + * collection — Directus-Collection (Pflicht) + * search — Freitext-Suche (wird als Directus _contains-Filter angewandt) + * limit — Max. Ergebnisse (default: 50, max: 200) + */ + +const express = require('express'); +const directus = require('../services/directus'); +const { authenticate } = require('../middleware/auth'); +const config = require('../config'); + +const router = express.Router(); + +// Felder, die für die Frontend-Vorschau zurückgegeben werden (keine sensiblen Felder) +const PREVIEW_FIELDS = ['id', 'vorname', 'nachname', 'ort', 'plz']; + +router.get('/', authenticate, async (req, res, next) => { + const { collection, search } = req.query; + const limit = Math.min( + parseInt(req.query.limit || '50', 10), + config.directus.maxRecipients + ); + + if (!collection || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(collection)) { + return res.status(400).json({ error: 'collection fehlt oder ungültig.' }); + } + + const filter = {}; + if (search && typeof search === 'string' && search.length >= 2) { + // Suche in Vor- und Nachname + filter._or = [ + { vorname: { _contains: search.slice(0, 100) } }, + { nachname: { _contains: search.slice(0, 100) } }, + ]; + } + + try { + const recipients = await directus.getRecipients({ + collection, + fields: PREVIEW_FIELDS, + filter, + }); + + res.json({ data: recipients.slice(0, limit), total: recipients.length }); + } catch (err) { + next(err); + } +}); + +module.exports = router; diff --git a/serienbrief-backend/src/routes/render.js b/serienbrief-backend/src/routes/render.js new file mode 100644 index 0000000..b75a80c --- /dev/null +++ b/serienbrief-backend/src/routes/render.js @@ -0,0 +1,141 @@ +'use strict'; + +/** + * POST /api/sb/render + * + * Render-Endpunkt: Empfänger aus Directus laden, Template mergen, PDF zurückgeben. + * + * Request-Body: + * { + * "templateId": "uuid-aus-sb_templates", + * "collection": "kontakte", // Directus-Collection + * "recipientIds": [42, 87, 133], // Directus-IDs + * "extraFields": { "betreff": "..." } // Zusätzliche Template-Variablen + * } + * + * Response: application/pdf (Datei-Download) + */ + +const express = require('express'); +const rateLimit = require('express-rate-limit'); + +const config = require('../config'); +const directus = require('../services/directus'); +const templateCache = require('../services/templateCache'); +const pdfRenderer = require('../services/pdfRenderer'); +const auditLog = require('../services/auditLog'); +const { authenticate } = require('../middleware/auth'); +const logger = require('../services/logger'); + +const router = express.Router(); + +// Render-spezifischer Rate-Limiter (zusätzlich zum globalen) +const renderLimiter = rateLimit({ + windowMs: config.rateLimit.windowMs, + max: config.rateLimit.max, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Zu viele Render-Anfragen. Bitte warten.' }, + keyGenerator: (req) => req.user?.id || req.ip, // Per-User-Limit +}); + +router.post('/', authenticate, renderLimiter, async (req, res, next) => { + const { templateId, collection, recipientIds = [], extraFields = {} } = req.body; + + // ── Eingabe-Validierung ────────────────────────────────────────────────── + if (!templateId || typeof templateId !== 'string') { + return res.status(400).json({ error: 'templateId fehlt oder ungültig.' }); + } + if (!collection || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(collection)) { + return res.status(400).json({ error: 'collection fehlt oder ungültig.' }); + } + if (!Array.isArray(recipientIds) || recipientIds.length === 0) { + return res.status(400).json({ error: 'recipientIds muss ein nicht-leeres Array sein.' }); + } + if (recipientIds.length > config.directus.maxRecipients) { + return res.status(400).json({ + error: `Maximal ${config.directus.maxRecipients} Empfänger pro Request erlaubt.`, + }); + } + + try { + // ── 1. Template-Metadaten laden ───────────────────────────────────────── + const templates = await directus.getTemplateList(); + const tmplMeta = templates.find(t => t.id === templateId); + + if (!tmplMeta) { + return res.status(404).json({ error: `Template ${templateId} nicht gefunden.` }); + } + + // ── 2. Template-Datei laden (aus Cache oder Directus) ─────────────────── + const templateBuffer = await templateCache.getTemplate(tmplMeta.file_id, tmplMeta); + const filename = `template_${tmplMeta.name}.${tmplMeta.file_id.includes('.odt') ? 'odt' : 'docx'}`; + + // ── 3. Empfänger-Daten aus Directus laden ─────────────────────────────── + // Felder-Allowlist aus Template-Metadaten (falls vorhanden) oder Default + const allowedFields = tmplMeta.allowed_fields + ? tmplMeta.allowed_fields.split(',').map(f => f.trim()) + : ['id', 'vorname', 'nachname', 'titel', 'adresse', 'plz', 'ort', 'land']; + + const recipients = await directus.getRecipients({ + collection, + fields: allowedFields, + ids: recipientIds, + }); + + if (recipients.length === 0) { + return res.status(404).json({ error: 'Keine Empfänger gefunden.' }); + } + + logger.info({ + userId: req.user.id, + templateId, + recipientCount: recipients.length, + }, 'Render-Job gestartet'); + + // ── 4. Merge-Daten aufbereiten ─────────────────────────────────────────── + // Carbone erwartet { d: { ... } } für Einzelbriefe oder Array für Batch. + // Hier: ein PDF pro Render-Request (alle Empfänger in einem Dokument mit + // Carbone-Schleifen-Syntax). Für einzelne PDFs: Schleife + ZIP. + const mergeData = { + d: { + empfaenger: recipients, + meta: { + datum: new Date().toLocaleDateString('de-AT', { day: '2-digit', month: '2-digit', year: 'numeric' }), + ...extraFields, + }, + }, + }; + + // ── 5. PDF rendern ─────────────────────────────────────────────────────── + const pdfBuffer = await pdfRenderer.renderToPdf(templateBuffer, mergeData, filename); + + // ── 6. Audit-Log (vor Response, damit kein Race-Condition-Problem) ─────── + await auditLog.log({ + userId: req.user.id, + userEmail: req.user.email, + templateId: tmplMeta.id, + templateVersion: tmplMeta.version, + recipientCount: recipients.length, + recipientIds: recipients.map(r => String(r.id)), + ipAddress: req.ip, + userAgent: req.headers['user-agent'], + }); + + // ── 7. PDF streamen ────────────────────────────────────────────────────── + const safeFilename = `serienbrief_${new Date().toISOString().slice(0, 10)}.pdf`; + + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${safeFilename}"`); + res.setHeader('Content-Length', pdfBuffer.length); + res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + + res.end(pdfBuffer); + + } catch (err) { + next(err); + } +}); + +module.exports = router; diff --git a/serienbrief-backend/src/routes/templates.js b/serienbrief-backend/src/routes/templates.js new file mode 100644 index 0000000..2416f34 --- /dev/null +++ b/serienbrief-backend/src/routes/templates.js @@ -0,0 +1,38 @@ +'use strict'; + +/** + * Template-Routen + * + * GET /api/sb/templates — Liste aller aktiven Templates + * POST /api/sb/templates/reload — Cache leeren (nur intern / Admin-Token) + */ + +const express = require('express'); +const directus = require('../services/directus'); +const templateCache = require('../services/templateCache'); +const { authenticate } = require('../middleware/auth'); + +const router = express.Router(); + +// Liste aller aktiven Templates +router.get('/', authenticate, async (req, res, next) => { + try { + const templates = await directus.getTemplateList(); + // file_id nicht nach außen geben (kein direkter Asset-Zugriff für User) + const safe = templates.map(({ id, name, description, version }) => + ({ id, name, description, version }) + ); + res.json({ data: safe }); + } catch (err) { + next(err); + } +}); + +// Cache-Reload (z.B. nach Template-Upload in Directus) +// Nur intern erreichbar – im Konzept ohne Proxy-Routing nach außen +router.post('/reload', async (req, res) => { + templateCache.invalidateAll(); + res.json({ message: 'Template-Cache geleert.' }); +}); + +module.exports = router; diff --git a/serienbrief-backend/src/server.js b/serienbrief-backend/src/server.js new file mode 100644 index 0000000..8ab2e0c --- /dev/null +++ b/serienbrief-backend/src/server.js @@ -0,0 +1,143 @@ +'use strict'; + +/** + * Serienbrief-Backend — Express.js Entry Point + * + * Startreihenfolge: + * 1. Konfiguration validieren + * 2. Audit-DB Schema initialisieren + * 3. Express-App konfigurieren + * 4. HTTP-Server starten + * 5. Graceful-Shutdown registrieren + */ + +const express = require('express'); +const helmet = require('helmet'); +const rateLimit = require('express-rate-limit'); +const { pinoHttp } = require('pino-http'); + +const config = require('./config'); +const logger = require('./services/logger'); +const auditLog = require('./services/auditLog'); +const { errorHandler } = require('./middleware/errorHandler'); + +// ── Routen ─────────────────────────────────────────────────────────────────── +const renderRoute = require('./routes/render'); +const templatesRoute = require('./routes/templates'); +const recipientsRoute = require('./routes/recipients'); +const auditRoute = require('./routes/audit'); + +// ── App-Setup ──────────────────────────────────────────────────────────────── +const app = express(); + +// Trust Proxy (Traefik sitzt davor) +app.set('trust proxy', 1); + +// ── Security-Header ────────────────────────────────────────────────────────── +app.use(helmet({ + contentSecurityPolicy: false, // Wird vom Reverse Proxy gesetzt + crossOriginEmbedderPolicy: false, +})); + +// ── Request-Logging (kein PII) ─────────────────────────────────────────────── +app.use(pinoHttp({ + logger, + // Keine Query-Parameter loggen (könnten PII enthalten) + customLogLevel: (req, res, err) => err ? 'error' : res.statusCode >= 400 ? 'warn' : 'info', + serializers: { + req(req) { + return { method: req.method, url: req.url.split('?')[0], id: req.id }; + }, + }, +})); + +// ── Body-Parser ─────────────────────────────────────────────────────────────── +app.use(express.json({ limit: '64kb' })); // Kein großer JSON-Body nötig + +// ── Globaler Rate-Limiter (alle Endpunkte) ──────────────────────────────────── +app.use(rateLimit({ + windowMs: 60_000, + max: 100, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Zu viele Anfragen. Bitte warten.' }, +})); + +// ── Health-Endpunkt (kein Auth, für Docker-Healthcheck und Monitoring) ──────── +app.get('/health', (req, res) => { + res.json({ + status: 'ok', + version: process.env.npm_package_version || '1.0.0', + uptime: Math.floor(process.uptime()), + renderer: process.env.RENDERER || 'gotenberg', + timestamp: new Date().toISOString(), + }); +}); + +// ── API-Routen ──────────────────────────────────────────────────────────────── +app.use('/api/sb/render', renderRoute); +app.use('/api/sb/templates', templatesRoute); +app.use('/api/sb/recipients', recipientsRoute); +app.use('/api/sb/audit', auditRoute); + +// Interne Admin-Routen (kein Proxy-Routing nach außen) +app.post('/admin/reload-templates', (req, res) => { + const templateCache = require('./services/templateCache'); + templateCache.invalidateAll(); + res.json({ message: 'Template-Cache geleert.' }); +}); + +// ── 404 ─────────────────────────────────────────────────────────────────────── +app.use((req, res) => { + res.status(404).json({ error: 'Endpunkt nicht gefunden.' }); +}); + +// ── Fehlerhandler ───────────────────────────────────────────────────────────── +app.use(errorHandler); + +// ── Start ───────────────────────────────────────────────────────────────────── +async function start() { + try { + // Audit-Schema anlegen (idempotent) + await auditLog.initSchema(); + logger.info('Audit-DB Schema bereit'); + } catch (err) { + logger.error({ err }, 'Audit-DB Initialisierung fehlgeschlagen – Server startet trotzdem'); + // Kein harter Abbruch: Server soll starten, auch wenn Audit-DB kurz nicht erreichbar ist. + // Der Audit-Log-Service fängt Fehler intern ab. + } + + const server = app.listen(config.server.port, () => { + logger.info({ + port: config.server.port, + env: config.server.nodeEnv, + directus: config.directus.url, + renderer: process.env.RENDERER || 'gotenberg', + }, 'Serienbrief-Backend gestartet'); + }); + + // ── Graceful Shutdown ──────────────────────────────────────────────────────── + function shutdown(signal) { + logger.info({ signal }, 'Shutdown eingeleitet'); + server.close(() => { + logger.info('HTTP-Server geschlossen'); + process.exit(0); + }); + + // Erzwingter Exit nach 10 Sekunden + setTimeout(() => { + logger.error('Graceful Shutdown Timeout – erzwungener Exit'); + process.exit(1); + }, 10_000); + } + + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); + + // Unbehandelte Promise-Rejections loggen (kein Crash in Produktion, aber Alarm) + process.on('unhandledRejection', (reason) => { + logger.error({ reason }, 'Unbehandelte Promise-Rejection'); + }); +} + +start(); diff --git a/serienbrief-backend/src/services/auditLog.js b/serienbrief-backend/src/services/auditLog.js new file mode 100644 index 0000000..df259a9 --- /dev/null +++ b/serienbrief-backend/src/services/auditLog.js @@ -0,0 +1,156 @@ +'use strict'; + +/** + * Audit-Log Service + * + * Schreibt jeden Render-Vorgang unveränderlich in die sb_audit_log-Tabelle. + * + * DSGVO-Anforderungen: + * - Kein Speichern personenbezogener Inhalte (kein Name, Adresse, Inhalt). + * - Nur Directus-IDs (recipient_ids) → Verbindung zu Person nur über Directus herstellbar. + * - Der DB-User hat REVOKE UPDATE, DELETE (nur INSERT möglich). + * - user_email wird als Snapshot gespeichert (User kann später gelöscht werden). + * + * Schema (PostgreSQL): + * CREATE TABLE sb_audit_log ( + * id BIGSERIAL PRIMARY KEY, + * created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + * user_id TEXT NOT NULL, + * user_email TEXT NOT NULL, + * template_id TEXT NOT NULL, + * template_version TEXT, + * recipient_count INTEGER NOT NULL, + * recipient_ids TEXT[], + * ip_address INET, + * user_agent TEXT, + * action TEXT NOT NULL DEFAULT 'render_pdf' + * ); + * REVOKE UPDATE, DELETE ON sb_audit_log FROM sb_audit_user; + */ + +const { Pool } = require('pg'); +const config = require('../config'); +const logger = require('./logger'); + +let pool = null; + +function getPool() { + if (!pool) { + pool = new Pool({ + host: config.audit.host, + port: config.audit.port, + database: config.audit.database, + user: config.audit.user, + password: config.audit.password, + ssl: config.audit.ssl ? { rejectUnauthorized: true } : false, + max: 5, // Kleine Pool-Größe für Audit-DB (wenig parallele Writes) + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 5000, + }); + + pool.on('error', (err) => { + logger.error({ err }, 'Audit-DB Pool-Fehler'); + }); + } + return pool; +} + +/** + * Schreibt einen Audit-Log-Eintrag. + * + * @param {object} entry + * @param {string} entry.userId - Directus-User-UUID + * @param {string} entry.userEmail - E-Mail-Snapshot + * @param {string} entry.templateId - Template-ID (Directus UUID oder Dateiname) + * @param {string} [entry.templateVersion] + * @param {number} entry.recipientCount + * @param {string[]} [entry.recipientIds] - Directus-IDs der Empfänger (kein PII) + * @param {string} [entry.ipAddress] + * @param {string} [entry.userAgent] + * @param {string} [entry.action] - Default: 'render_pdf' + * @returns {Promise} + */ +async function log(entry) { + const { + userId, + userEmail, + templateId, + templateVersion = null, + recipientCount, + recipientIds = [], + ipAddress = null, + userAgent = null, + action = 'render_pdf', + } = entry; + + const query = ` + INSERT INTO sb_audit_log + (user_id, user_email, template_id, template_version, + recipient_count, recipient_ids, ip_address, user_agent, action) + VALUES ($1, $2, $3, $4, $5, $6, $7::inet, $8, $9) + `; + + const values = [ + userId, + userEmail, + templateId, + templateVersion, + recipientCount, + recipientIds.map(String), // Sicherstellen: nur Strings im Array + ipAddress, + userAgent ? userAgent.slice(0, 512) : null, // User-Agent begrenzen + action, + ]; + + try { + await getPool().query(query, values); + logger.info({ userId, templateId, recipientCount, action }, 'Audit-Log geschrieben'); + } catch (err) { + // Audit-Log-Fehler darf den PDF-Response nicht blockieren, + // muss aber klar geloggt und ggf. alarmiert werden. + logger.error({ err, userId, templateId }, 'KRITISCH: Audit-Log-Eintrag fehlgeschlagen'); + // In Produktion: Alert auslösen (z.B. via Alertmanager/Prometheus) + } +} + +/** + * Gibt die letzten N Einträge zurück (für Admin-Ansicht / Makefile-Befehl). + * @param {number} limit + * @returns {Promise} + */ +async function getRecent(limit = 50) { + const res = await getPool().query( + `SELECT id, created_at, user_email, template_id, template_version, + recipient_count, ip_address, action + FROM sb_audit_log + ORDER BY created_at DESC + LIMIT $1`, + [limit] + ); + return res.rows; +} + +/** + * Initialisiert die Tabelle, falls sie noch nicht existiert. + * Wird beim Server-Start einmalig ausgeführt. + */ +async function initSchema() { + await getPool().query(` + CREATE TABLE IF NOT EXISTS sb_audit_log ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + user_id TEXT NOT NULL, + user_email TEXT NOT NULL, + template_id TEXT NOT NULL, + template_version TEXT, + recipient_count INTEGER NOT NULL, + recipient_ids TEXT[], + ip_address INET, + user_agent TEXT, + action TEXT NOT NULL DEFAULT 'render_pdf' + ); + `); + logger.info('Audit-DB Schema initialisiert'); +} + +module.exports = { log, getRecent, initSchema }; diff --git a/serienbrief-backend/src/services/directus.js b/serienbrief-backend/src/services/directus.js new file mode 100644 index 0000000..489d160 --- /dev/null +++ b/serienbrief-backend/src/services/directus.js @@ -0,0 +1,110 @@ +'use strict'; + +/** + * Directus-Client + * + * Kapselt alle Zugriffe auf die Directus REST-API. + * Verwendet einen statischen Service-Account-Token (Docker Secret). + * + * Sicherheitsprinzipien: + * - Nur die explizit benötigten Felder werden abgefragt (Field Projection). + * - Kein GraphQL – REST erlaubt feingranularere Kontrolle über Logs/Audits. + * - Kein direktes User-Token-Forwarding; der Service-Account hat minimale Rechte. + */ + +const config = require('../config'); +const logger = require('./logger'); + +// node-fetch ist ESM; bei CommonJS-Projekten mit Node 18+ kann globalThis.fetch genutzt werden. +// Ab Node 21 ist fetch built-in. Hier: kompatibler Wrapper. +async function apiFetch(path, options = {}) { + const url = `${config.directus.url}${path}`; + const headers = { + 'Authorization': `Bearer ${config.directus.token}`, + 'Content-Type': 'application/json', + ...(options.headers || {}), + }; + + const res = await fetch(url, { ...options, headers }); + + if (!res.ok) { + const body = await res.text(); + logger.error({ url, status: res.status, body }, 'Directus API-Fehler'); + throw new Error(`Directus API ${res.status}: ${res.statusText}`); + } + + return res.json(); +} + +/** + * Liefert eine Liste von Empfängern aus einer Directus-Collection. + * + * @param {string} collection - Name der Directus-Collection + * @param {string[]} fields - Erlaubte Felder (Allowlist aus Template-Metadaten) + * @param {object} filter - Directus-Filterausdruck (JSON) + * @param {number[]} ids - Optionale ID-Liste für gezielte Abfrage + * @returns {Promise} + */ +async function getRecipients({ collection, fields, filter = {}, ids = [] }) { + if (!collection || !fields || fields.length === 0) { + throw new Error('collection und fields sind Pflichtparameter.'); + } + + // Felder-Allowlist: niemals '*' zulassen + const safeFields = fields.filter(f => f !== '*' && /^[\w.]+$/.test(f)); + if (safeFields.length === 0) { + throw new Error('Keine gültigen Felder in der Allowlist.'); + } + + const params = new URLSearchParams(); + params.set('fields', safeFields.join(',')); + params.set('limit', String(config.directus.maxRecipients)); + + // ID-Filter hat Vorrang vor freiem Filter + if (ids.length > 0) { + params.set('filter[id][_in]', ids.join(',')); + } else if (Object.keys(filter).length > 0) { + params.set('filter', JSON.stringify(filter)); + } + + const data = await apiFetch(`/items/${encodeURIComponent(collection)}?${params}`); + return data.data || []; +} + +/** + * Lädt eine einzelne Template-Datei aus dem Directus File-Manager. + * + * @param {string} fileId - UUID aus directus_files + * @returns {Promise} + */ +async function getTemplateFile(fileId) { + const url = `${config.directus.url}/assets/${fileId}?download`; + const res = await fetch(url, { + headers: { 'Authorization': `Bearer ${config.directus.token}` }, + }); + + if (!res.ok) { + throw new Error(`Template-Download fehlgeschlagen: ${res.status} ${res.statusText}`); + } + + const buffer = await res.arrayBuffer(); + return Buffer.from(buffer); +} + +/** + * Liefert die Liste aller aktiven Templates aus der sb_templates-Collection. + * + * @returns {Promise} + */ +async function getTemplateList() { + const params = new URLSearchParams({ + fields: 'id,name,description,version,file_id,active', + 'filter[active][_eq]': 'true', + sort: 'name', + }); + + const data = await apiFetch(`/items/sb_templates?${params}`); + return data.data || []; +} + +module.exports = { getRecipients, getTemplateFile, getTemplateList }; diff --git a/serienbrief-backend/src/services/logger.js b/serienbrief-backend/src/services/logger.js new file mode 100644 index 0000000..0316fdd --- /dev/null +++ b/serienbrief-backend/src/services/logger.js @@ -0,0 +1,19 @@ +'use strict'; + +const pino = require('pino'); +const config = require('../config'); + +const logger = pino({ + level: config.log.level, + // JSON-Logging in Produktion; pretty-print in dev + transport: config.server.nodeEnv === 'development' + ? { target: 'pino-pretty', options: { colorize: true } } + : undefined, + // Keine sensiblen Felder loggen + redact: { + paths: ['req.headers.authorization', 'req.headers.cookie', '*.password', '*.token'], + censor: '[REDACTED]', + }, +}); + +module.exports = logger; diff --git a/serienbrief-backend/src/services/pdfRenderer.js b/serienbrief-backend/src/services/pdfRenderer.js new file mode 100644 index 0000000..21a551c --- /dev/null +++ b/serienbrief-backend/src/services/pdfRenderer.js @@ -0,0 +1,143 @@ +'use strict'; + +/** + * PDF-Renderer + * + * Unterstützt zwei Modi: + * 1. Gotenberg (REST-API, empfohlen für Produktion) + * 2. Carbone lokal (LibreOffice im selben Container, einfacher für Dev/klein) + * + * Umschalten über Umgebungsvariable RENDERER=gotenberg|carbone (default: gotenberg) + * + * Sicherheit: + * - Generierte PDF-Buffer werden direkt in die HTTP-Response gestreamt. + * - Keine temporären Dateien im Container-Dateisystem (außer /tmp via tmpfs). + * - Keine Persistenz von PDF-Inhalten. + */ + +const path = require('path'); +const os = require('os'); +const fs = require('fs'); +const FormData = require('form-data'); +const config = require('../config'); +const logger = require('./logger'); + +const RENDERER = process.env.RENDERER || 'gotenberg'; + +// ─── Gotenberg ─────────────────────────────────────────────────────────────── + +/** + * Konvertiert einen DOCX/ODT-Buffer via Gotenberg LibreOffice-Modul nach PDF. + * + * @param {Buffer} docBuffer - Dokument-Buffer (DOCX oder ODT) + * @param {string} filename - Dateiname inkl. Erweiterung (z.B. "brief.docx") + * @returns {Promise} - PDF als Buffer + */ +async function renderViaGotenberg(docBuffer, filename) { + const form = new FormData(); + form.append('files', docBuffer, { + filename, + contentType: filename.endsWith('.odt') + ? 'application/vnd.oasis.opendocument.text' + : 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), config.pdfRenderer.timeoutMs); + + try { + const res = await fetch( + `${config.pdfRenderer.url}/forms/libreoffice/convert`, + { + method: 'POST', + body: form, + headers: form.getHeaders(), + signal: controller.signal, + } + ); + + if (!res.ok) { + const errText = await res.text(); + throw new Error(`Gotenberg Fehler ${res.status}: ${errText}`); + } + + const arrayBuffer = await res.arrayBuffer(); + return Buffer.from(arrayBuffer); + } finally { + clearTimeout(timeout); + } +} + +// ─── Carbone (lokale LibreOffice-Integration) ───────────────────────────────── + +/** + * Rendert ein Template via Carbone.js (LibreOffice im selben Container). + * + * @param {Buffer} templateBuffer - ODT/DOCX-Template + * @param {object} data - Merge-Daten { d: { ... } } + * @returns {Promise} + */ +async function renderViaCarbone(templateBuffer, data) { + const carbone = require('carbone'); + + return new Promise((resolve, reject) => { + // Carbone benötigt eine physische Datei; /tmp liegt auf tmpfs + const tmpFile = path.join(os.tmpdir(), `tmpl_${Date.now()}_${Math.random().toString(36).slice(2)}.odt`); + + fs.writeFileSync(tmpFile, templateBuffer); + + carbone.render(tmpFile, data, { convertTo: 'pdf' }, (err, result) => { + // Temporäre Template-Datei sofort löschen + try { fs.unlinkSync(tmpFile); } catch (_) { /* ignore */ } + + if (err) return reject(new Error(`Carbone Render-Fehler: ${err.message}`)); + resolve(result); + }); + }); +} + +// ─── Öffentliche API ────────────────────────────────────────────────────────── + +/** + * Rendert ein Dokument nach PDF. + * Wählt Renderer basierend auf RENDERER-Umgebungsvariable. + * + * @param {Buffer} templateBuffer - Template-Datei als Buffer + * @param {object} data - Merge-Daten (werden direkt an Carbone übergeben) + * @param {string} filename - Dateiname des Templates + * @returns {Promise} + */ +async function renderToPdf(templateBuffer, data, filename) { + logger.info({ renderer: RENDERER, filename }, 'PDF-Render gestartet'); + const start = Date.now(); + + let pdfBuffer; + + if (RENDERER === 'carbone') { + // Für Carbone: Template wird mit Daten gemergt, dann konvertiert + pdfBuffer = await renderViaCarbone(templateBuffer, data); + } else { + // Für Gotenberg: Template wird erst mit Carbone gemergt (DOCX mit Variablen), + // dann das fertige Dokument an Gotenberg zur PDF-Konvertierung übergeben. + // + // Alternativ: Nur Gotenberg (ohne Carbone) für statische Dokumente. + const carbone = require('carbone'); + const tmpFile = path.join(os.tmpdir(), `tmpl_${Date.now()}.odt`); + fs.writeFileSync(tmpFile, templateBuffer); + + const mergedDoc = await new Promise((resolve, reject) => { + carbone.render(tmpFile, data, {}, (err, result) => { + try { fs.unlinkSync(tmpFile); } catch (_) { /* ignore */ } + if (err) return reject(err); + resolve(result); + }); + }); + + pdfBuffer = await renderViaGotenberg(mergedDoc, filename); + } + + logger.info({ renderer: RENDERER, filename, durationMs: Date.now() - start }, 'PDF-Render abgeschlossen'); + return pdfBuffer; +} + +module.exports = { renderToPdf }; diff --git a/serienbrief-backend/src/services/templateCache.js b/serienbrief-backend/src/services/templateCache.js new file mode 100644 index 0000000..4d3c916 --- /dev/null +++ b/serienbrief-backend/src/services/templateCache.js @@ -0,0 +1,67 @@ +'use strict'; + +/** + * Template-Cache + * + * Hält geladene Template-Puffer im Arbeitsspeicher (TTL-basiert). + * Verhindert bei jedem Render-Request einen Directus-API-Call. + * + * Sicherheitshinweis: + * - Templates werden als Buffer gespeichert, nie als String (kein XSS-Risiko). + * - Reload-Endpunkt ist nur intern erreichbar (kein Proxy-Routing nach außen). + */ + +const config = require('../config'); +const logger = require('./logger'); +const directus = require('./directus'); + +/** @type {Map} */ +const cache = new Map(); + +/** + * Liefert ein Template als Buffer, ggf. aus dem Cache. + * + * @param {string} fileId - Directus-File-UUID + * @param {object} meta - Template-Metadaten für Logging + * @returns {Promise} + */ +async function getTemplate(fileId, meta = {}) { + const now = Date.now(); + const cached = cache.get(fileId); + + if (cached && cached.expiresAt > now) { + logger.debug({ fileId }, 'Template aus Cache geladen'); + return cached.buffer; + } + + logger.info({ fileId, name: meta.name }, 'Template von Directus laden'); + const buffer = await directus.getTemplateFile(fileId); + + cache.set(fileId, { + buffer, + expiresAt: now + config.templates.cacheTtlMs, + meta, + }); + + return buffer; +} + +/** + * Leert den gesamten Template-Cache (z.B. nach Template-Upload). + */ +function invalidateAll() { + const count = cache.size; + cache.clear(); + logger.info({ count }, 'Template-Cache vollständig geleert'); +} + +/** + * Entfernt einen einzelnen Eintrag aus dem Cache. + * @param {string} fileId + */ +function invalidate(fileId) { + cache.delete(fileId); + logger.info({ fileId }, 'Template aus Cache entfernt'); +} + +module.exports = { getTemplate, invalidateAll, invalidate }; diff --git a/serienbrief_konzept_directus.md b/serienbrief_konzept_directus.md new file mode 100644 index 0000000..5e4a36e --- /dev/null +++ b/serienbrief_konzept_directus.md @@ -0,0 +1,852 @@ +# Architekturkonzept: Serienbrief-System mit Directus + +**Version:** 1.0 +**Umgebung:** Ubuntu Server 24.04 LTS, Docker Engine 27.x, Docker Compose V2 +**Zielgruppe:** Erfahrener Linux-/Docker-Admin, Data Protection Officer im Gesundheitssektor (Österreich) +**Stand:** 2025 + +--- + +## Inhaltsverzeichnis + +1. [Kontextabgrenzung und Annahmen](#1-kontextabgrenzung-und-annahmen) +2. [Architekturübersicht](#2-architekturübersicht) +3. [Stack-Vergleich: Option A vs. Option B](#3-stack-vergleich-option-a-vs-option-b) +4. [Datenfluss im Detail](#4-datenfluss-im-detail) +5. [Template-Management](#5-template-management) +6. [PDF-Rendering: Optionsvergleich](#6-pdf-rendering-optionsvergleich) +7. [Security und DSGVO-Compliance](#7-security-und-dsgvo-compliance) +8. [Docker Compose Grundstruktur](#8-docker-compose-grundstruktur) +9. [Empfehlung und Entscheidungsbegründung](#9-empfehlung-und-entscheidungsbegründung) + +--- + +## 1. Kontextabgrenzung und Annahmen + +### Ausgangslage + +- Directus läuft bereits als Container (mit PostgreSQL-Backend) +- Adressdaten, ggf. Gesundheitsreferenzen in Directus-Collections +- Serienbrief-Nutzer sind interne Mitarbeitende (kein anonymer Zugriff) +- Briefe werden als PDF ausgegeben; optionaler E-Mail-Versand ist Out-of-Scope dieses Konzepts +- Kein Kubernetes; Docker Compose bleibt der Orchestrierer + +### Gesetzliche Rahmenbedingungen (Österreich) + +Gesundheitsdaten fallen unter **Art. 9 Abs. 1 DSGVO** – die höchste Schutzstufe. Relevante österreichische Ergänzungen: + +- **DSG (Datenschutzgesetz)** 2018 i.d.g.F. konkretisiert Art. 9-Ausnahmen +- **GTelG 2012 / ELGA** für elektronische Gesundheitsdaten im engeren Sinn +- Jede neue Verarbeitungsaktivität ist im **Verzeichnis der Verarbeitungstätigkeiten (VVT)** zu dokumentieren (Art. 30 DSGVO) +- Bei hohem Risiko: **Datenschutz-Folgenabschätzung (DSFA/DPIA)** gem. Art. 35 DSGVO erforderlich + +> **Praktische Konsequenz für das System:** Alle personenbezogenen Gesundheitsdaten dürfen den Self-Hosted-Perimeter **niemals** verlassen. Jeder externe Service (Cloud-PDF-Renderer, CDN, externe Template-Stores) ist unzulässig, solange kein Auftragsverarbeitungsvertrag (AVV) vorliegt und keine Rechtsgrundlage nach Art. 9 Abs. 2 gegeben ist. + +--- + +## 2. Architekturübersicht + +### Komponentendiagramm (ASCII) + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Ubuntu Server 24.04 LTS │ +│ Docker Engine 27.x │ +│ │ +│ ┌──────────────────┐ ┌─────────────────────────────────────┐ │ +│ │ Reverse Proxy │ │ Docker-Netzwerk │ │ +│ │ (Traefik/Caddy) │ │ serienbrief_net │ │ +│ │ Port 443 (TLS) │ │ │ │ +│ └────────┬─────────┘ │ ┌────────────┐ ┌──────────────┐ │ │ +│ │ │ │ Directus │ │ Serienbrief │ │ │ +│ │ /api/sb/* │ │ (exist.) │ │ Backend │ │ │ +│ ├──────────────┼─▶│ :8055 │ │ (Node.js) │ │ │ +│ │ │ │ │◀─│ :3001 │ │ │ +│ │ /sb/* │ └─────┬──────┘ └──────┬───────┘ │ │ +│ ├──────────────┼────────┼─────────────────┼──────────┤ │ +│ │ │ │ │ │ │ +│ │ │ ┌─────▼──────┐ ┌──────▼───────┐ │ │ +│ │ (SPA) │ │ PostgreSQL │ │ Gotenberg/ │ │ │ +│ │ │ │ (exist.) │ │ Carbone │ │ │ +│ │ │ │ :5432 │ │ :3002 │ │ │ +│ │ │ └────────────┘ └──────────────┘ │ │ +│ │ │ │ │ +│ │ │ ┌──────────────┐ ┌────────────┐ │ │ +│ │ │ │ Template- │ │ Audit-DB │ │ │ +│ │ │ │ Store Vol. │ │ (Postgres │ │ │ +│ │ │ │ (bind/vol) │ │ Schema) │ │ │ +│ │ │ └──────────────┘ └────────────┘ │ │ +│ │ └─────────────────────────────────────┘ │ +│ │ │ +│ ┌────────▼──────────┐ │ +│ │ Browser-Client │ │ +│ │ (Intranet only) │ │ +│ └───────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### Zusammenspiel der Komponenten + +| Komponente | Rolle | Kommunikation | +| ----------------------- | -------------------------------------------------------------------- | ------------------------------------- | +| **Directus** | Adressdatenbank, Auth-Provider (RBAC), ggf. Template-Storage | REST/GraphQL, intern | +| **Serienbrief-Backend** | Orchestrierung: Daten holen, Template mergen, PDF-Job triggern | intern → Directus API, → PDF-Renderer | +| **PDF-Renderer** | Konvertierung DOCX/ODT/HTML → PDF (stateless, keine Datenpersistenz) | intern, Job-basiert | +| **Template-Store** | Docker Volume / Directus-Files für Vorlagenverwaltung | Datei-Mount | +| **Reverse Proxy** | TLS-Terminierung, Rate-Limiting, Auth-Header-Forwarding | extern → intern | +| **Audit-DB** | Persistenter Audit-Trail (wer, was, wann) | Backend → PostgreSQL-Schema | + +--- + +## 3. Stack-Vergleich: Option A vs. Option B + +### Option A: Leichtgewichtig — Carbone.js + Node.js Backend + Vue/React SPA + +#### Beschreibung + +**Carbone.js** ist ein Open-Source-Report-Generator (AGPL-3.0 Community, kommerzielle Lizenz verfügbar), der JSON-Daten in ODT/DOCX-Templates injiziert und via LibreOffice-Integration nach PDF konvertiert. Das Backend ist ein schlanker **Express.js**-Service; das Frontend eine einfache SPA (Vue 3 oder plain HTML). + +#### Architektur-Sketch + +``` +Vue SPA ──POST /render──▶ Express.js ──REST──▶ Directus API + │ + ├──carbone.render()──▶ LibreOffice (lokal im Container) + │ │ PDF + └──────────────────────────▼──────▶ HTTP-Response (Download) +``` + +#### Vor- und Nachteile + +| Kriterium | Bewertung | +| -------------------- | ------------------------------------------------------------------------------------------------------------------ | +| **Komplexität** | ✅ Gering – ein Node.js-Container, kein externer Renderer-Dienst | +| **Performance** | ✅ ~50 ms/PDF bei warmem LibreOffice-Worker; kalt ~3–5 s | +| **Template-Format** | ✅ ODT/DOCX mit Mustache-ähnlicher Syntax (`{d.feldname}`), vertraut für Office-User | +| **Lizenz** | ⚠️ AGPL-3.0 für Community-Edition — bei interner Nutzung unkritisch; bei SaaS-Weiterverteilung Lizenzprüfung nötig | +| **Wartbarkeit** | ✅ Kleiner Footprint, gut testbar, wenig externe Abhängigkeiten | +| **DSGVO-Relevanz** | ✅ Vollständig self-hosted; kein Datentransfer nach außen | +| **Overhead** | ⚠️ LibreOffice im Container: Image-Größe ~1,5–2 GB; RAM-Bedarf ~600 MB idle | +| **Frontend-Aufwand** | ⚠️ Custom SPA muss selbst entwickelt/gepflegt werden | +| **Skalierung** | ⚠️ LibreOffice-Worker ist prozessbasiert; parallele Requests erfordern mehrere Worker oder Queue | + +#### Lizenz-Klarstellung Carbone + +Die **On-Premise Docker Edition** ist frei nutzbar (Community-Features). Kostenpflichtige Lizenzen werden nur für Enterprise-Features (erweitertes Template-Management, API-Rate-Limits, Support) benötigt. Für ein internes Serienbriefwerkzeug reicht die Community-Edition vollständig aus. + +--- + +### Option B: Vollwertig — Gotenberg + Budibase/Appsmith als Low-Code-Frontend + +#### Beschreibung + +**Gotenberg** ist ein containerisierter PDF-Konverter-Dienst (MIT-Lizenz), der über eine REST-API DOCX-, HTML- und andere Formate via LibreOffice oder Headless Chromium in PDF umwandelt. Als Frontend wird **Budibase** (Self-Hosted, GPL-3.0/Budibase-Lizenz) oder **Appsmith** (Apache 2.0, Self-Hosted) eingesetzt — beides Low-Code-Builder mit Directus-Konnektoren. + +#### Architektur-Sketch + +``` +Budibase UI ──Query Builder──▶ Directus API (Daten holen) + │ + ├──Custom Automation──▶ Serienbrief-Backend (leichter Glue-Service) + │ + ├──DOCX template + JSON data──▶ Gotenberg REST API + │ │ PDF + └──────────────────────────────────────▼──▶ Response +``` + +#### Vor- und Nachteile + +| Kriterium | Bewertung | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Komplexität** | ⚠️ Höher: Budibase + Gotenberg + ggf. Backend-Glue = 3+ zusätzliche Container | +| **Frontend-Aufwand** | ✅ Gering – Low-Code-Builder; kein Frontend-Entwickler nötig | +| **Template-Format** | ⚠️ Gotenberg akzeptiert DOCX (via LibreOffice) oder HTML (via Chromium); Template-Variablen-Handling nicht nativ | +| **Gotenberg-Performance** | ✅ LibreOffice-Variante: vergleichbar mit Carbone; Chromium-Variante: höherer RAM-Bedarf | +| **Lizenz Gotenberg** | ✅ MIT – keine Einschränkungen | +| **Lizenz Budibase** | ⚠️ Budibase v2+ nutzt eine eigene "Budibase Personal License" für Self-Hosted; GPL nur für ältere Versionen; Enterprise-Features kostenpflichtig | +| **Lizenz Appsmith** | ✅ Apache 2.0 für Community-Edition | +| **DSGVO-Relevanz** | ✅ Vollständig self-hosted möglich; Budibase/Appsmith senden keine Daten nach außen (Telemetrie deaktivierbar) | +| **Wartbarkeit** | ⚠️ Budibase/Appsmith sind komplexe Eigenanwendungen mit eigenem Lifecycle | +| **Resource-Footprint** | ⚠️ Budibase benötigt eigene DB + Backend-Services; Appsmith ebenso | +| **Skalierung** | ✅ Gotenberg skaliert horizontal einfach | + +#### Kritische Anmerkung zu Budibase-Lizenz + +Ab Budibase v2 gilt eine proprietäre Self-Hosted-Lizenz für kostenfreie Tier-Nutzung. Bei Änderungen am Source-Code entsteht eine Pflicht zur Veröffentlichung nur für AGPL-Teile. Für reine interne Nutzung ohne Modifikation ist das unproblematisch – dennoch Lizenztext vor Deployment genau lesen. + +--- + +### Direktvergleich + +| Kriterium | Option A (Carbone + Node.js) | Option B (Gotenberg + Budibase) | +| ------------------------------------ | ---------------------------------------- | ------------------------------- | +| **Entwicklungsaufwand initial** | Mittel (Custom Backend + SPA) | Niedrig (Low-Code) | +| **Langfristiger Pflegeaufwand** | Niedrig | Mittel–Hoch | +| **Container-Anzahl** | 3 (Backend + LibreOffice-intern + Proxy) | 5–7 | +| **RAM-Bedarf (idle)** | ~700 MB | ~1,5–2,5 GB | +| **Template-Komplexität** | Mittel (Mustache-Syntax in ODT/DOCX) | Niedrig–Mittel | +| **DSGVO-Risiko** | Sehr gering | Gering (mit Konfiguration) | +| **Audit-Trail** | Muss selbst implementiert werden | Budibase hat einfaches Logging | +| **Lizenz-Risiko** | Gering (AGPL intern) | Mittel (Budibase-Lizenz) | +| **Empfehlung für Gesundheitssektor** | **Bevorzugt** | Bedingt geeignet | + +--- + +## 4. Datenfluss im Detail + +### Schritt-für-Schritt + +``` +1. AUTHENTIFIZIERUNG + Browser → Reverse Proxy (TLS) → Serienbrief-Backend + Backend validiert Directus-JWT oder statisches API-Token + → Directus /auth/login oder Bearer-Token-Validierung + +2. EMPFÄNGER-SELEKTION + Frontend → GET /api/sb/recipients?filter=... + Backend → Directus REST: GET /items/{collection}?filter[status][_eq]=aktiv&fields=id,vorname,nachname,adresse,... + Directus prüft RBAC (Policy: nur erlaubte Felder zurückgeben) + Backend → Response: Array von Kontakten (minimiert auf benötigte Felder) + +3. TEMPLATE-AUSWAHL + Frontend → GET /api/sb/templates + Backend → liest Template-Verzeichnis (Volume-Mount) oder Directus /files?folder=templates + Response: Liste verfügbarer Vorlagen mit Metadaten + +4. RENDER-JOB + Frontend → POST /api/sb/render + { + "templateId": "anschreiben_v3.odt", + "recipients": [42, 87, 133], ← Directus-IDs + "extraFields": {"betreff": "..."} + } + +5. DATEN-ANREICHERUNG + Backend → Directus: GET /items/{collection}/{id} für jeden Empfänger + → Merge: Template-Platzhalter ↔ Kontaktfelder + → Sanitize: HTML-Encoding, keine Script-Injection möglich + +6. RENDER + Backend → Carbone.render(templateBuffer, dataObject, {convertTo: 'pdf'}) + oder: + Backend → POST http://gotenberg:3000/forms/libreoffice/convert + multipart: template.docx + data (via DOCX-Variablen vormerged) + +7. AUDIT-LOG (synchron, vor Response) + Backend → INSERT INTO audit_log(user_id, template_id, recipient_count, timestamp, ip) + +8. RESPONSE + Backend → HTTP 200, Content-Type: application/pdf + Browser → Download-Dialog oder In-Browser-Preview + +9. DATENBEREINIGUNG + Temporäre Dateien im Container: sofortige Löschung nach Response + Kein Persistieren von generierten PDFs (nur bei explizitem Speicher-Feature) +``` + +### Sequenzdiagramm (vereinfacht) + +``` +Browser Backend Directus PDF-Renderer Audit-DB + │ │ │ │ │ + ├─POST /render──▶│ │ │ │ + │ ├─GET /items───▶│ │ │ + │ │◀─JSON data────┤ │ │ + │ ├─render(tmpl,data)──────────────▶│ │ + │ │◀──────────────────────PDF bytes─┤ │ + │ ├─INSERT audit──────────────────────────────────▶│ + │◀─PDF response──┤ │ │ │ +``` + +--- + +## 5. Template-Management + +### Anforderungen + +- Templates als ODT/DOCX (LibreOffice-editierbar) +- Versionierung +- Zugriffssteuerung: nicht jeder darf Templates bearbeiten +- Einfaches UI zum Upload ohne Entwickler-Eingriff + +### Option 5A: Directus als Template-Store (empfohlen) + +Directus hat ein eingebautes File-Management mit: + +- Upload-UI +- Folder-Struktur +- Metadaten-Felder (Version, Beschreibung, Ersteller, Datum) +- RBAC: eigene Rolle `template_admin` mit Schreibrecht auf den `/files`-Endpoint + +``` +Directus Collection: sb_templates +├── id (UUID) +├── name (String) +├── description (String) +├── version (String, z.B. "2.1") +├── file_id (FK → directus_files) +├── active (Boolean) +├── created_by (FK → directus_users) +└── created_at (DateTime) +``` + +Das Backend lädt Templates via `GET /files/{file_id}` und cached sie lokal (Volume-Mount oder In-Memory-Cache mit kurzer TTL). + +**Vorteil:** Single Source of Truth; Versionierung über Directus-Revisionen; keine zusätzliche Infrastruktur. + +### Option 5B: Dediziertes Volume mit Versionierungs-Convention + +Einfacher für pure Admins ohne Directus-Wissen: + +``` +/data/serienbrief/templates/ +├── anschreiben/ +│ ├── anschreiben_v1.0.odt +│ ├── anschreiben_v1.1.odt ← symlink: anschreiben_current.odt +│ └── anschreiben_v2.0.odt +├── info_brief/ +│ ├── ... +└── metadata.json ← Name, aktive Version, Beschreibung +``` + +Das Backend liest `metadata.json` und exponiert eine Template-Liste. Deployment neuer Templates via `scp` oder Git-Webhook. + +**Nachteil:** Kein GUI-Upload; kein integriertes RBAC. + +### Empfehlung: Option 5A (Directus als Template-Store) + +Da Directus bereits läuft, ergibt sich kein Mehraufwand. Die RBAC-Integration verhindert, dass unberechtigte Nutzer Templates überschreiben – kritisch im regulierten Umfeld. + +--- + +## 6. PDF-Rendering: Optionsvergleich + +| Renderer | Technik | Docker-Image | RAM-Bedarf | Schrift-Support | DSGVO-OK | Empfehlung | +| --------------------------------- | ---------------------------------- | --------------------------------- | ---------------- | ------------------------------- | -------- | ------------------------------------------------------ | +| **LibreOffice via Carbone** | LibreOffice headless | `carbone-env-docker` (~1,8 GB) | ~400–600 MB idle | ✅ Systemfonts + TTF-Install | ✅ | ✅ **Empfohlen für DOCX/ODT** | +| **Gotenberg (LibreOffice-Modul)** | LibreOffice headless | `gotenberg/gotenberg:8` (~1,5 GB) | ~400–800 MB | ✅ | ✅ | ✅ Gut, REST-First-Design | +| **Gotenberg (Chromium-Modul)** | Headless Chromium | `gotenberg/gotenberg:8` (~2,5 GB) | ~1–1,5 GB | ✅ (Web-Fonts nur wenn lokal) | ✅ | ⚠️ Für HTML-Templates sinnvoll, aber schwerer | +| **WeasyPrint** | Python, CSS-basiertes PDF aus HTML | `python:3.12-slim` + apt | ~150–300 MB | ⚠️ Nur installierte Systemfonts | ✅ | ⚠️ Gut für HTML-Templates; DOCX nicht möglich | +| **Puppeteer/headless Chrome** | Node.js + Chromium | ~1,5 GB | ~800–1.200 MB | ✅ | ✅ | ⚠️ Komplex (seccomp!), für interne Lösung Overkill | +| **wkhtmltopdf** | Qt WebKit | veraltete Engine | ~200 MB | ⚠️ | ✅ | ❌ Nicht empfohlen (veraltet, keine aktive Entwicklung) | + +### Sicherheitshinweis: Puppeteer/Chromium in Docker + +Headless Chrome benötigt privilegierte Capabilities oder ein angepasstes **seccomp-Profil**. Der häufig verwendete `--no-sandbox`-Flag ist in einer produktionsnahen Umgebung mit nicht-vertrauenswürdigen Templates **inakzeptabel**. Korrekte Konfiguration: + +```yaml +# In docker-compose.yml für Puppeteer-Container +security_opt: + - seccomp:./chrome-seccomp.json # Chromium-spezifisches seccomp-Profil +cap_drop: + - ALL +cap_add: + - SYS_ADMIN # Nur wenn kein seccomp-Profil; besser: SUID-Sandbox konfigurieren +``` + +**Für Gotenberg** entfällt dieses Problem, da Gotenberg intern diese Härtung bereits vornimmt und eine sauber definierte API-Grenze bietet. + +### Empfehlung PDF-Renderer + +**Gotenberg v8 (LibreOffice-Modul)** oder **Carbone mit integriertem LibreOffice** für DOCX/ODT-Templates. Begründung: + +- MIT-Lizenz, aktiv maintained (Stand 2025) +- Sauber entkoppelte REST-API; kein direktes LibreOffice-Prozess-Management im Backend nötig +- Keine `--no-sandbox`-Probleme +- Unterstützt DOCX, ODT, XLSX, PPTX → Flexibilität für künftige Erweiterungen + +--- + +## 7. Security und DSGVO-Compliance + +### 7.1 Architekturelle Datenschutz-Maßnahmen + +#### Datenminimierung (Art. 5 Abs. 1 lit. c DSGVO) + +Das Backend darf von Directus **nur die Felder abfragen, die das Template tatsächlich benötigt**. Implementierung: + +```javascript +// Schlechte Praxis: +const contact = await directus.get(`/items/contacts/${id}`); + +// Korrekte Praxis (Directus field projection): +const contact = await directus.get( + `/items/contacts/${id}?fields=id,vorname,nachname,adresse,plz,ort` +); +``` + +Felder-Allowlist pro Template in der Template-Metadaten-Collection speichern. + +#### Keine Persistenz von generierten PDFs + +Generierte PDFs enthalten Gesundheitsdaten. **Sie werden nie persistiert**, sondern nur im RAM gehalten und direkt als HTTP-Response gestreamt: + +```javascript +// Kein fs.writeFileSync() für finale PDFs +// Stattdessen: +carbone.render(template, data, {convertTo: 'pdf'}, (err, pdfBuffer) => { + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', 'attachment; filename="serienbrief.pdf"'); + res.send(pdfBuffer); + // Buffer wird vom GC freigegeben +}); +``` + +#### Netzwerkisolation + +Alle Services kommunizieren ausschließlich im internen Docker-Netzwerk. Nur der Reverse Proxy ist von außen erreichbar: + +```yaml +networks: + serienbrief_net: + internal: true # Kein direkter Internet-Zugang für Backend/Renderer + proxy_net: + driver: bridge # Nur Proxy hat Zugang nach außen +``` + +### 7.2 Authentifizierung und Autorisierung + +#### Directus RBAC + +Für das Serienbrief-System wird eine dedizierte Directus-Policy angelegt: + +| Rolle | Erlaubte Operationen | +| ------------ | ------------------------------------------------------------------------- | +| `sb_user` | Lesen: freigegebene Collections (kein Zugriff auf raw Gesundheitsfelder) | +| `sb_admin` | Lesen + Schreiben auf `sb_templates`; Einsehen von `sb_audit_log` | +| `sb_service` | Service-Account für das Backend; nur Lese-Zugriff auf Kontakt-Collections | + +Das Backend-Service-Token wird als Docker Secret verwaltet (nie in `.env`-Dateien einchecken): + +```yaml +secrets: + directus_token: + file: ./secrets/directus_service_token.txt + +services: + serienbrief-backend: + secrets: + - directus_token + environment: + DIRECTUS_TOKEN_FILE: /run/secrets/directus_token +``` + +#### Session-Management im Frontend + +Wenn eine Custom-SPA eingesetzt wird: **keine** persistente Token-Speicherung in `localStorage`. Stattdessen `sessionStorage` oder HttpOnly-Cookie mit kurzem TTL (max. 4 Stunden für regulierten Kontext). + +### 7.3 Audit-Log + +Ein Audit-Log ist für den Gesundheitssektor nicht optional. Jede Serienbrief-Generierung erzeugt einen unveränderlichen Log-Eintrag: + +```sql +CREATE TABLE sb_audit_log ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + user_id TEXT NOT NULL, -- Directus-User-UUID + user_email TEXT NOT NULL, -- Snapshot, da User gelöscht werden können + template_id TEXT NOT NULL, + template_version TEXT, + recipient_count INTEGER NOT NULL, + recipient_ids TEXT[], -- Arrays: welche Kontakt-IDs betroffen + ip_address INET, + user_agent TEXT, + action TEXT NOT NULL DEFAULT 'render_pdf', + -- Keine Nutzdaten (kein PDF-Inhalt, keine personenbezogenen Felder) + CONSTRAINT no_update CHECK (true) -- Ergänzt durch DB-Grants: kein UPDATE/DELETE +); + +-- Nur INSERT erlaubt für den Service-Account +REVOKE UPDATE, DELETE ON sb_audit_log FROM sb_service_user; +``` + +**Wichtig:** Der Audit-Log selbst enthält `recipient_ids` (Directus-IDs), aber **keine personenbezogenen Felder** (kein Name, keine Adresse). Die Verbindung zur Person ist nur über Directus herstellbar und bleibt so auflösbar. + +### 7.4 TLS und Transportverschlüsselung + +- Reverse Proxy terminiert TLS ≥ 1.2 (TLS 1.3 bevorzugen) +- Interne Container-Kommunikation: im internen Docker-Netzwerk ohne TLS akzeptabel, solange das Host-System gehärtet ist (kein Fremdzugriff auf Docker-Socket) +- **Volumes mit sensiblen Daten** (Template-Store): LUKS-verschlüsseltes Dateisystem auf Host-Ebene empfohlen + +### 7.5 Härtungsmaßnahmen für Container + +```yaml +# Best-Practice-Snippet für alle Serienbrief-Services +services: + serienbrief-backend: + image: serienbrief-backend:1.0.0 + user: "1001:1001" # Non-root + read_only: true # Read-only Rootfs + tmpfs: + - /tmp:size=256m,mode=1777 # Schreibbarer Temp-Bereich + security_opt: + - no-new-privileges:true + - seccomp:./seccomp/default.json + cap_drop: + - ALL + deploy: + resources: + limits: + memory: 512M + cpus: "1.0" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:3001/health"] + interval: 30s + timeout: 5s + retries: 3 +``` + +### 7.6 DSGVO-Checkliste für das Deployment + +| Anforderung | Maßnahme | Status | +| -------------------------------- | ------------------------------------------------------------------------- | -------------------- | +| Art. 9 – Gesundheitsdaten Schutz | Kein Datentransfer nach außen; self-hosted | ☐ Vor Go-Live prüfen | +| Art. 30 – VVT | Serienbrief-System als neue Verarbeitungstätigkeit dokumentieren | ☐ DSB/Admin | +| Art. 32 – TOMs | Verschlüsselung (TLS, LUKS), Zugriffssteuerung, Audit-Log | ☐ Vor Go-Live | +| Art. 35 – DSFA | Bei hohem Risiko (Massenverarbeitung Gesundheitsdaten): DPIA durchführen | ☐ DSB-Entscheidung | +| Art. 17 – Löschrecht | Löschkonzept für generierte PDFs (keine Persistenz = automatisch erfüllt) | ☐ Dokumentieren | +| Berufsgeheimnis (§ 54 ÄrzteG AT) | Zugriff nur für autorisiertes Personal; RBAC | ☐ Rollen definieren | + +--- + +## 8. Docker Compose Grundstruktur + +Die folgende Struktur ist **als Ausgangspunkt konzipiert** – nicht als vollständige Produktionskonfiguration. Environment-spezifische Werte (Passwörter, Tokens) gehören in `.env`-Dateien, die **nicht ins Git-Repository** eingecheckt werden. + +``` +/opt/serienbrief/ +├── docker-compose.yml +├── docker-compose.override.yml # Lokale Dev-Overrides +├── .env # Nicht ins Repo! +├── secrets/ +│ ├── directus_service_token.txt +│ └── db_password.txt +├── config/ +│ ├── traefik/ +│ │ └── traefik.yml +│ └── seccomp/ +│ └── default.json +├── templates/ # Bind-Mount für Template-Store (Option B) +└── Makefile # Deployment-Shortcuts +``` + +```yaml +# docker-compose.yml +# Hinweis: Directus und PostgreSQL existieren bereits in einem separaten Compose-Projekt. +# Dieses Compose-File referenziert das externe Netzwerk über `external: true`. + +name: serienbrief + +networks: + serienbrief_int: + driver: bridge + internal: true # Kein Internet-Zugang für interne Services + proxy_net: + external: true # Bestehendes Proxy-Netzwerk (Traefik/Caddy) + directus_net: + external: true # Bestehendes Directus-Netzwerk + +secrets: + directus_token: + file: ./secrets/directus_service_token.txt + db_audit_password: + file: ./secrets/db_audit_password.txt + +volumes: + sb_templates: + driver: local + driver_opts: + type: none + o: bind + device: /opt/serienbrief/templates + sb_audit_db_data: + +services: + + # ───────────────────────────────────────────── + # Serienbrief-Backend (Node.js / Express) + # ───────────────────────────────────────────── + serienbrief-backend: + image: ghcr.io/meine-org/serienbrief-backend:${SB_VERSION:-latest} + restart: unless-stopped + user: "1001:1001" + read_only: true + tmpfs: + - /tmp:size=256m,mode=1777 + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + secrets: + - directus_token + - db_audit_password + environment: + NODE_ENV: production + DIRECTUS_URL: http://directus:8055 # internes Netzwerk + DIRECTUS_TOKEN_FILE: /run/secrets/directus_token + PDF_RENDERER_URL: http://gotenberg:3000 # oder carbone-service + AUDIT_DB_HOST: sb-audit-db + AUDIT_DB_NAME: sb_audit + AUDIT_DB_USER: sb_audit_user + AUDIT_DB_PASSWORD_FILE: /run/secrets/db_audit_password + TEMPLATE_STORE_PATH: /templates + LOG_LEVEL: info + volumes: + - sb_templates:/templates:ro # Templates read-only + networks: + - serienbrief_int + - directus_net + - proxy_net + labels: + # Traefik-Labels (anpassen an eigene Traefik-Version) + - "traefik.enable=true" + - "traefik.http.routers.sb-backend.rule=Host(`intern.example.at`) && PathPrefix(`/api/sb`)" + - "traefik.http.routers.sb-backend.tls=true" + - "traefik.http.services.sb-backend.loadbalancer.server.port=3001" + # Rate-Limiting Middleware (konfiguriert in Traefik) + - "traefik.http.routers.sb-backend.middlewares=ratelimit-sb@file" + deploy: + resources: + limits: + memory: 512M + cpus: "1.0" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:3001/health"] + interval: 30s + timeout: 5s + retries: 3 + + # ───────────────────────────────────────────── + # PDF-Renderer: Gotenberg v8 (LibreOffice-Modul) + # ───────────────────────────────────────────── + gotenberg: + image: gotenberg/gotenberg:8 + restart: unless-stopped + # Gotenberg läuft intern als non-root + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + command: + - "gotenberg" + - "--api-timeout=30s" + - "--libreoffice-restart-after=100" # Verhindert Memory-Leaks bei vielen Konvertierungen + - "--log-level=info" + - "--log-format=json" + # Chromium deaktivieren (nicht benötigt, reduziert Angriffsfläche) + - "--chromium-disable-routes=true" + networks: + - serienbrief_int + deploy: + resources: + limits: + memory: 1G + cpus: "2.0" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/health"] + interval: 30s + timeout: 10s + retries: 3 + + # ───────────────────────────────────────────── + # Audit-Datenbank (dedizierte PostgreSQL-Instanz) + # ───────────────────────────────────────────── + sb-audit-db: + image: postgres:16-alpine + restart: unless-stopped + user: "70:70" # postgres user in alpine image + read_only: true + tmpfs: + - /var/run/postgresql:uid=70,gid=70 + - /tmp:uid=70,gid=70 + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + environment: + POSTGRES_DB: sb_audit + POSTGRES_USER: sb_audit_user + POSTGRES_PASSWORD_FILE: /run/secrets/db_audit_password + secrets: + - db_audit_password + volumes: + - sb_audit_db_data:/var/lib/postgresql/data + networks: + - serienbrief_int + command: + - postgres + - -c + - log_connections=on + - -c + - log_disconnections=on + - -c + - log_statement=ddl # DDL loggen; DML über Audit-Trigger + - -c + - max_connections=20 # Serienbrief-Backend nutzt wenige Connections + deploy: + resources: + limits: + memory: 256M + healthcheck: + test: ["CMD-SHELL", "pg_isready -U sb_audit_user -d sb_audit"] + interval: 30s + timeout: 5s + retries: 5 + + # ───────────────────────────────────────────── + # (Optional) Serienbrief-Frontend (Static SPA) + # ───────────────────────────────────────────── + serienbrief-frontend: + image: nginx:1.27-alpine + restart: unless-stopped + read_only: true + tmpfs: + - /var/cache/nginx:uid=101 + - /var/run:uid=101 + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: + - NET_BIND_SERVICE # Port 80 als non-root + volumes: + - ./frontend/dist:/usr/share/nginx/html:ro + - ./config/nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro + networks: + - proxy_net + labels: + - "traefik.enable=true" + - "traefik.http.routers.sb-frontend.rule=Host(`intern.example.at`) && PathPrefix(`/sb`)" + - "traefik.http.routers.sb-frontend.tls=true" + deploy: + resources: + limits: + memory: 64M + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:80/"] + interval: 30s + timeout: 5s + retries: 3 +``` + +### Makefile-Hilfsbefehle + +```makefile +# /opt/serienbrief/Makefile + +.PHONY: up down logs audit-tail update-templates health + +up: + docker compose pull && docker compose up -d + +down: + docker compose down --remove-orphans + +logs: + docker compose logs -f --tail=100 + +audit-tail: + docker compose exec sb-audit-db psql -U sb_audit_user -d sb_audit \ + -c "SELECT created_at, user_email, template_id, recipient_count, ip_address \ + FROM sb_audit_log ORDER BY created_at DESC LIMIT 50;" + +# Template-Deployment ohne Container-Neustart +update-templates: + rsync -av --checksum ./templates/ /opt/serienbrief/templates/ + docker compose exec serienbrief-backend \ + wget -qO- http://localhost:3001/admin/reload-templates + +health: + docker compose ps + docker compose exec serienbrief-backend wget -qO- http://localhost:3001/health + docker compose exec gotenberg curl -s http://localhost:3000/health +``` + +--- + +## 9. Empfehlung und Entscheidungsbegründung + +### Empfohlener Stack + +**Option A mit Gotenberg als eigenständigem Renderer** + +``` +Carbone.js (Template-Engine, Mustache-Syntax) + + +Gotenberg v8 – LibreOffice-Modul (REST-PDF-Renderer, MIT-Lizenz) + + +Node.js/Express Backend (Orchestrierung, Audit-Log) + + +Directus als Template-Store + Adressdatenbank (bereits vorhanden) + + +Schlanke Vue 3 SPA oder Directus-eigene Flows für UI +``` + +#### Begründung + +**1. Kontrolle und Transparenz über die gesamte Pipeline** + +Als erfahrener Admin und Data Protection Officer ist die vollständige Nachvollziehbarkeit des Datenflusses entscheidend. Eine Low-Code-Plattform wie Budibase abstrahiert Interna, die im regulierten Umfeld dokumentiert werden müssen. Ein eigenes, überschaubares Node.js-Backend (~300–500 LoC) ist vollständig auditierbar. + +**2. DSGVO-Konformität by Design** + +- Kein Datentransfer nach außen; alle Komponenten self-hosted +- Kein `localStorage` für Tokens; kein Telemetrie-Risiko +- Audit-Log ist fester Bestandteil, nicht nachgerüstet +- Datenfeldminimierung ist im Backend-Code erzwingbar; bei Low-Code-Lösungen oft umgehbar + +**3. Lizenzrisiko ist beherrschbar** + +Carbone AGPL-3.0 ist für interne, nicht weiterverteilte Nutzung unproblematisch. Gotenberg MIT hat keinerlei Einschränkungen. Eine Budibase-Proprietär-Lizenz birgt mehr langfristiges Risiko. + +**4. Ressourceneffizienz** + +Der vorgeschlagene Stack benötigt ~1,5–2 GB RAM. Option B mit Budibase würde allein für die Plattform ~1–2 GB zusätzlich beanspruchen – auf einem dedizierten Ubuntu-Server im Gesundheitssektor mit definierten Ressourcen kein unwesentlicher Faktor. + +**5. Wartbarkeit für einen Admin ohne Frontend-Entwickler** + +Das Backend ist mit Standard-Node.js-Werkzeugen wartbar. Gotenberg-Updates sind trivial (`docker pull`). Templates werden über Directus verwaltet – die Nutzer kennen diese Oberfläche bereits. + +**6. Upgrade-Pfad ist klar** + +Sollte das System wachsen (mehr parallele Nutzer, größere Batches), kann: + +- Gotenberg horizontal skaliert werden (mehrere Replicas im Swarm) +- Eine Job-Queue (Bull/BullMQ mit Redis) vor den PDF-Renderer geschaltet werden +- Das Frontend durch Budibase/Appsmith ersetzt werden, ohne Backend-Änderungen + +### Entscheidungsbaum + +``` +Hast du Frontend-Entwickler verfügbar? +├── Nein ──▶ Directus Flows + Backend-API (minimales Frontend reicht) +│ oder Appsmith (Apache 2.0, weniger Lizenzrisiko als Budibase) +└── Ja ──▶ Vue 3 SPA (vollständige Kontrolle, DSGVO-sauber) + +Werden mehr als 50 PDFs/Stunde generiert? +├── Nein ──▶ Carbone mit integriertem LibreOffice (einfachste Option) +└── Ja ──▶ Gotenberg als dedizierter Service + Job-Queue + +Sollen Templates von Nicht-Technikern erstellt werden? +├── Nein ──▶ ODT/DOCX-Dateien im Directus-File-Manager +└── Ja ──▶ Directus-Collection mit Upload-UI + Validierung +``` + +### Nicht empfohlen (mit Begründung) + +| Option | Grund | +| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| Cloud-basierter PDF-Service (DocuSign, Adobe, Cloudmersive) | Drittlandtransfer von Gesundheitsdaten; DSGVO-konformer AVV meist nicht ausreichend für Art. 9-Daten | +| Puppeteer/headless Chrome selbst verwaltet | Sicherheitskomplexität (seccomp, sandbox); wartungsintensiv; Gotenberg bietet dieselbe Funktionalität mit weniger Aufwand | +| wkhtmltopdf | Keine aktive Entwicklung seit 2020; bekannte Rendering-Schwächen; sicherheitsrelevante Bugs ungepatcht | +| Budibase als primäre Datenquelle (statt Directus) | Würde bestehende Directus-Infrastruktur verdoppeln; Datenduplikation mit DSGVO-Implikationen | + +--- + +### Abschlussbewertung + +Das vorgeschlagene System hält sich an das Prinzip **minimale Komplexität bei maximaler Auditierbarkeit**. Für einen Data Protection Officer im Gesundheitssektor ist nicht die technische Eleganz entscheidend, sondern die Fähigkeit, bei einer Prüfung durch die österreichische Datenschutzbehörde (DSB) jeden Schritt der Datenverarbeitung lückenlos erklären und belegen zu können. Ein überschaubares Node.js-Backend mit explizitem Audit-Log und klarer Netzwerkisolation erfüllt diese Anforderung besser als jede Black-Box-Low-Code-Plattform. + +--- + +*Erstellt auf Basis von: [Carbone.js GitHub](https://github.com/carboneio/carbone), [Gotenberg Dokumentation](https://gotenberg.dev), [Directus Docs](https://directus.io/docs/api), [Art. 9 DSGVO](https://dsgvo-gesetz.de/art-9-dsgvo/), [Österreichisches DSG 2018](https://www.ris.bka.gv.at), [Sozialministerium AT – Schutz sensibler Daten](https://www.sozialministerium.gv.at), [Puppeteer Troubleshooting](https://pptr.dev/troubleshooting)*