initial commit
This commit is contained in:
@@ -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)*
|
||||
Reference in New Issue
Block a user