initial commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
secrets/
|
||||
templates/
|
||||
.env*
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.env
|
||||
secrets/
|
||||
*.log
|
||||
dist/
|
||||
@@ -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"]
|
||||
@@ -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 |
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user