initial commit

This commit is contained in:
2026-05-20 09:24:11 +02:00
commit b2a4bfa537
22 changed files with 2150 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
.git
.gitignore
*.md
secrets/
templates/
.env*
+40
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
node_modules/
.env
secrets/
*.log
dist/
+46
View File
@@ -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"]
+93
View File
@@ -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 <directus-user-token>
{
"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 |
+28
View File
@@ -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"
}
}
+77
View File
@@ -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;
@@ -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 <access_token>
*
* 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 };
@@ -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 };
+33
View File
@@ -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;
@@ -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;
+141
View File
@@ -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;
@@ -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;
+143
View File
@@ -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();
@@ -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<void>}
*/
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<object[]>}
*/
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 };
@@ -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<object[]>}
*/
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<Buffer>}
*/
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<object[]>}
*/
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 };
@@ -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;
@@ -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<Buffer>} - 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<Buffer>}
*/
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<Buffer>}
*/
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 };
@@ -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<string, { buffer: Buffer, expiresAt: number, meta: object }>} */
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<Buffer>}
*/
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 };