Compare commits

...
16 Commits
Author SHA1 Message Date
florian.wilde c5db3baac0 Merge: Version 1.12.0 2026-09-01 14:40:22 +00:00
Florian Wilde 274d7666ea chore: Version 1.12.0 2026-09-01 16:39:59 +02:00
florian.wilde 890e00fb5c Merge #46: Wo die Daten liegen, und drei Fallen aus dem Betrieb 2026-09-01 14:37:25 +00:00
Florian Wilde 042e63812b Anleitung: wo die Daten liegen, und drei Fallen aus dem Betrieb
Aus einer Fehlersuche am 31.08.2026 sind vier Luecken in der Anleitung
uebriggeblieben. Keine davon war falsch formuliert - alle vier waren
schlicht nicht durchdacht.

1. Es stand nirgends, wo die Daten liegen. Der neue Abschnitt nennt die
   drei Orte, zeigt den Pfad auf der Platte, sagt ausdruecklich, dass dort
   keine anklickbaren PDFs liegen, und beschreibt den Abgleich zwischen
   Akte und Speicher: Zu jedem Eintrag muss eine Datei gehoeren, sonst
   belegt eine Pruefsumme ein Dokument, das es nicht mehr gibt.

2. Der mc-Alias wurde einmalig gesetzt. Er liegt in /root/.mc IM
   Behaelter und ist nach jedem "docker compose up -d minio" weg -
   "mc ls lokal" liefert dann kommentarlos nichts, und man haelt seine
   Dokumente fuer verschwunden. Genau das ist passiert. Jeder Aufruf setzt
   den Alias jetzt selbst, aus den Variablen des Behaelters.

3. Der Bucket-Name stand fest im Befehl, die Anwendung liest ihn aus
   S3_BUCKET. Weichen sie ab, zeigen beide aneinander vorbei.

4. "set -a && source .env" wurde empfohlen, ohne zu sagen, dass die
   exportierten Werte danach jedes "docker compose up" ueberstimmen. Wer
   spaeter die .env aendert und neu startet, sieht keine Wirkung und sucht
   den Fehler ueberall, nur nicht in der eigenen Shell. Der Kniff ist
   nicht mehr noetig - der neue Befehl kommt ohne ihn aus - und die Falle
   steht als Erklaerung daneben.

Dazu docker-compose.override.yml in der .gitignore und in der Anleitung
als vorgesehener Ort fuer Anpassungen, die ein Update ueberleben sollen:
update.sh prueft nur versionierte Dateien und laesst sie in Ruhe.

Die Aussage zur Sicherung ist mitgezogen - sie stimmt seit #41 nicht mehr,
die Dokumente sind jetzt enthalten.

Neun Pruefungen in tests/anleitung.test.ts, gegen vier Mutationen
geprueft: Name fest verdrahtet, Alias nicht gesetzt, source .env zurueck,
Abschnitt entfernt - jede wird rot.

Refs #46
2026-09-01 16:36:48 +02:00
florian.wilde 53dde70f26 Merge #44: Speicherfehler erklären und die Ablage von selbst prüfen 2026-09-01 14:31:51 +00:00
Florian Wilde 92c78a92ff Speicherfehler erklaeren und die Ablage von selbst pruefen
Beim Ablegen eines Schreibens stand in der Oberflaeche "Das Schreiben
konnte nicht abgelegt werden." Bei einer Fehlersuche am 31.08.2026 lagen
hinter genau diesem Satz nacheinander DREI verschiedene Ursachen: ein
fehlender Ablagebereich, ein Endpunkt, der auf einen fremden Anbieter
zeigte, und eine Verschluesselung, die der Speicher nicht anbietet. Ohne
Zugang zum Serverlog waere die Suche aussichtslos gewesen.

Jetzt uebersetzt src/lib/speicher/fehler.ts die bekannten Faelle in
Saetze, die sagen, was zu tun ist - fehlender Ablagebereich mit Verweis
auf Schritt 6 der Installation, abgelehnte Zugangsdaten mit den Namen der
beiden Variablen, nicht unterstuetzte Verschluesselung mit dem Hinweis auf
S3_SSE, und nicht erreichbarer Speicher mit der Zusicherung, dass die
Eingaben bleiben.

Durchgereicht wird dabei nichts. Fremde Fehlertexte tragen Hostnamen,
Eimernamen und Anfragekennungen - das gehoert ins Log, nicht auf den
Bildschirm eines Betreuers. Uebersetzt werden nur die bekannten Faelle;
alles andere bleibt bei der allgemeinen Meldung.

Dazu eine Bereitschaftspruefung: Sie schreibt eine winzige Datei, liest
sie zurueck und raeumt sie weg - und beantwortet damit erreichbar,
Ablagebereich vorhanden und Schreibrecht auf einmal. Das Ergebnis steht in
/api/gesundheit und als Zeile im Log.

## Warum nicht instrumentation.ts

Der naheliegende Ort waere der Starthaken von Next. Der wird aber auch
fuer die Edge-Laufzeit gebuendelt, und ueber die Treiber kaeme node:path
in ein Buendel, in dem es das nicht gibt - der Build schlaegt fehl. Das
war ausprobiert, nicht vermutet.

Die Pruefung haengt deshalb an der Gesundheitsabfrage. Der Healthcheck
ruft sie ab 40 Sekunden nach dem Start alle 30 Sekunden auf; die erste
Pruefung passiert also von selbst kurz nach dem Hochfahren. Eine
Schonfrist von fuenf Minuten verhindert, dass dabei alle 30 Sekunden eine
Probedatei in den Objektspeicher geht, und gemeldet wird nur bei einer
Aenderung.

Ein gestoerter Speicher fuehrt ausdruecklich NICHT zu 503. Sonst startete
der Healthcheck den Behaelter neu und machte die ganze Akte unerreichbar -
wegen eines Teils, den ein Neustart nicht repariert.

Gegen drei Mutationen geprueft: NotImplemented ohne Textpruefung, alte
Reihenfolge bei der Codeermittlung, Erklaerung nicht benutzt - jede wird
rot.

Refs #44
2026-09-01 16:31:03 +02:00
florian.wilde c210f8bff0 Merge #45: Beispielkonfiguration entschärfen 2026-09-01 14:16:46 +00:00
Florian Wilde 5c6fac59b2 Beispielkonfiguration entschaerfen
.env.example belegte S3_ENDPOINT mit der Adresse eines externen
Anbieters. Gedacht war das als Beispiel - in der kopierten .env stand es
aber aktiv, und damit sprach die Anwendung mit einem fremden Host statt
mit dem Objektspeicher nebenan.

Verschaerfend: Die docker-compose.yml hatte laengst einen brauchbaren
Standard, ${S3_ENDPOINT:-http://minio:9000}. Der greift nur bei leerer
Variable - die Beispieldatei hebelte ihn aus. Der Mechanismus, der genau
diesen Fehler verhindern sollte, war durch die Beispieldatei wirkungslos.

Auf dem Livesystem hat das monatelang jede Ablage scheitern lassen, und
die Zugangs-ID des lokalen Speichers ging bei jedem Versuch nach draussen.

Die Zeile ist jetzt auskommentiert, mit einer Erklaerung, wann man sie
braucht.

Nebenbei stand dort SEED_ADMIN_EMAIL mit der Adresse des Betreibers. In
einer mitgelieferten Beispieldatei hat eine persoenliche Adresse nichts
zu suchen - jetzt leer.

Der Test prueft beides und dazu die allgemeine Regel: Kein aktiver Wert
in der Beispieldatei zeigt auf einen fremden Rechner. Er prueft auch,
dass ueberhaupt Zuweisungen gefunden werden - sonst staende er gruen da
und pruefte nichts.

Gegen zwei Mutationen geprueft: Endpunkt wieder aktiv, persoenliche
Adresse wieder eingetragen - beide werden rot.

Refs #45
2026-09-01 16:16:12 +02:00
florian.wilde ea34294fa7 Merge #43: Fehlende Umgebungsvariablen durchreichen 2026-09-01 14:13:08 +00:00
Florian Wilde 810716e4e4 Fehlende Umgebungsvariablen durchreichen - und dauerhaft absichern
Der Dienst "app" zaehlt seine Umgebungsvariablen einzeln auf; ein
env_file gibt es nicht. Drei Werte, die die Anwendung ausliest, standen
dort nicht: ERSTEINRICHTUNG_GEHEIMNIS, VERTRAUE_PROXY (beide seit v1.11.0)
und S3_SSE (seit v1.11.1).

Wer sie laut .env.example und INSTALLATION.md eintrug, brachte sie damit
nicht in den Behaelter - die Anleitung war an dieser Stelle schlicht
falsch. Besonders unangenehm bei VERTRAUE_PROXY: Der Wert fehlt still,
die Anmeldesperre zaehlt dann nur nach E-Mail-Adresse statt zusaetzlich
nach Anschluss, und im Betrieb faellt nichts auf.

VERTRAUE_PROXY bekommt bewusst KEINE Vorgabe "true". Die strengere
Einstellung bleibt die Voreinstellung; wer einen Proxy davorstellt, sagt
es ausdruecklich. Ohne Proxy ist der weitergereichte Kopf frei erfunden.

Der eigentliche Ertrag ist der Test: Er liest, welche Variablen env.ts
ausliest - ueber process.env ebenso wie ueber pflicht(), zahl(),
schalter() und schluessel32() -, vergleicht das mit dem, was die
Compose-Datei dem Dienst mitgibt, und meldet jede Luecke. Ausnahmen sind
aufgezaehlt und begruendet.

Damit wiederholt sich der Fehler beim naechsten neuen Wert nicht: Wer ihn
einfuehrt und das Durchreichen vergisst, sieht es sofort.

Gegen drei Mutationen geprueft: Wert wieder entfernt, Vorgabe "true"
gesetzt, S3_SSE entfernt - jede wird rot.

Refs #43
2026-09-01 16:12:32 +02:00
florian.wilde 88e9202c2f Merge #42: Serverseitige Verschlüsselung optional machen 2026-09-01 14:09:01 +00:00
Florian Wilde c0cb29bf53 Serverseitige Verschluesselung optional machen
Der S3-Treiber setzte bei jedem PutObject fest
ServerSideEncryption: 'AES256'. Das mitgelieferte MinIO laeuft ohne KMS
und lehnt das mit "NotImplemented" ab - es liess sich kein einziges
Dokument ablegen. Wer der INSTALLATION.md folgte, landete zwangslaeufig
dort; es war kein Konfigurationsfehler des Betreibers, sondern der
Auslieferungszustand.

Gebraucht wird die Zeile ohnehin nicht mehr. Seit v1.11.0 verschluesselt
die Anwendung jede Datei selbst, bevor sie den Server verlaesst. Eine
zweite Lage durch den Anbieter schuetzt vor nichts, was der eigene
Umschlag nicht schon abdeckt.

Jetzt steuert S3_SSE, ob der Kopf mitgeht. Leer bedeutet aus - auch eine
Zeile mit Leerzeichen, sonst schickte eine leergelassene .env-Zeile einen
leeren Kopf und der Anbieter antwortete mit einem Fehler, den niemand
versteht.

Im Kommentar von v1.11.0 stand, die serverseitige Verschluesselung
"kostet nichts". Das war falsch: Auf MinIO ohne KMS kostete sie die
gesamte Funktion. Der Satz ist korrigiert.

Aufgefallen ist das erst im Betrieb, und der Grund ist derselbe wie beim
Ausloeser: In der Entwicklung laeuft der Dateitreiber, der S3-Pfad wurde
von keinem Test ausgefuehrt. Deshalb pruefen die neuen Tests den Befehl
ab, bevor er das Netz sieht - was hineingeschrieben wird, ist jetzt
belegt, nicht angenommen.

Gegen drei Mutationen geprueft: SSE wieder fest verdrahtet, leere Angabe
nicht abgefangen, Inhalt unverschluesselt - jede wird rot.

Refs #42
2026-09-01 16:08:26 +02:00
florian.wilde dbc24d965d Merge #41: Dokumente mitsichern 2026-09-01 14:04:03 +00:00
Florian Wilde 4d826ef55f Dokumente mitsichern - eine Akte ohne ihre Dateien ist wertlos
Der Sicherungsdienst machte taeglich ein pg_dump und sonst nichts. Nach
einem Plattenschaden waere die Akte vollstaendig wiederhergestellt worden
- mit allen Eintraegen, Verweisen und Pruefsummen, aber ohne eine einzige
Datei dahinter. Die Pruefsumme in der Akte haette dann ein Dokument
belegt, das es nicht mehr gibt. Gegenueber dem Betreuungsgericht ist das
der schlechteste denkbare Zustand.

Je Lauf entstehen jetzt zwei verschluesselte Dateien mit demselben
Zeitstempel: der Datenbankstand und ein Archiv des Ablageverzeichnisses.
Beide mit Pruefsumme, beide in der Aufbewahrung, beide im Monatsstand,
beide in der Selbstpruefung.

Schlaegt der Dokumententeil fehl, bricht der Lauf ab und raeumt den
angefangenen Datenbankstand wieder weg. Eine Sicherung, die nur die halbe
Wahrheit enthaelt, aber vollstaendig aussieht, ist schlimmer als gar
keine: Man verlaesst sich darauf und merkt es erst im Ernstfall.

## Abweichung von der urspruenglichen Idee

Im Issue stand "mc mirror statt rohem Verzeichnis". Beim Umsetzen zeigte
sich, dass der MinIO-Client in diesem Behaelter fehlt und bei jedem Lauf
aus dem Netz geholt werden muesste. Eine Internetabhaengigkeit im
Sicherungspfad ist genau das, was man dort nicht will.

Stattdessen wird das Verzeichnis direkt eingepackt. Das ist vertretbar,
weil MinIO ein Objekt erst vollstaendig unter .minio.sys/tmp schreibt und
dann an seinen Platz umbenennt: Ein laufender Upload liegt nie halbfertig
im Archiv, er fehlt hoechstens. Derselbe Weg deckt den Dateitreiber mit
ab.

## Nur lesend

Der Sicherungsdienst bekommt das Ablageverzeichnis mit ":ro" eingehaengt.
Er laeuft unbeaufsichtigt per cron; ein Fehler im Skript oder ein
kompromittierter Behaelter wuerde sonst Sicherung und Bestand auf einmal
treffen - genau der Fall, gegen den eine Sicherung da ist.

Deshalb schreibt die Wiederherstellung die Dateien auch nicht selbst an
ihren Platz, sondern packt sie nach ./backups aus und gibt den letzten
Handgriff als Einzeiler aus. In ein laufendes MinIO hineinzuschreiben
wuerde dessen Datenbestand ohnehin beschaedigen.

## Geprueft

Die Kernlogik einmal von Hand durchgespielt: packen, verschluesseln,
Selbstpruefung, zurueckholen - der Inhalt kam bitgleich zurueck, der
Klartext war im Archiv nicht auffindbar, und die Selbstpruefung schlug
sowohl bei einem beschaedigten Archiv als auch bei falschem Passwort an.

Dazu elf Pruefungen in tests/skripte.test.ts gegen vier Mutationen:
Dokumententeil entfernt, Abbruch entfernt, Aufraeumen halbiert,
Schreibrecht erteilt - jede wird rot.

Refs #41
2026-09-01 16:03:26 +02:00
florian.wilde 6e6f022234 Merge #40: Ausschlussfristen nur bei Fälligkeit hervorheben 2026-09-01 13:50:59 +00:00
Florian Wilde b3823094d6 Ausschlussfristen nur hervorheben, wenn sie etwas von einem wollen
Auf dem Dashboard standen dauerhaft drei rote Kaesten mit Warndreieck und
Knopf "Frist jetzt bearbeiten": Aufwandspauschalen mit Verfall 2027, 2028
und 2029.

Alle anderen Bloecke des Dashboards filtern nach Dringlichkeit. Dieser
fragte nur, OB etwas eine Ausschlussfrist ist, und nie, WANN sie faellig
wird - eine Frist im Mai 2029 landete damit in derselben Aufmachung wie
eine, die morgen ablaeuft.

Die Daten sind richtig: Pflichtfristen werden bewusst drei Jahre im Voraus
angelegt. Sie gehoeren nur nicht in die Warnung, sondern in die Liste.

Neu ist ein Horizont von 90 Tagen. Er ist grosszuegig gewaehlt, weil die
kuerzeste hier vorkommende Ausschlussfrist sechs Monate laesst. Abgelaufene
warnen weiterhin immer - eine versaeumte Aufwandspauschale bleibt eine
schlechte Nachricht, auch wenn sie lange zurueckliegt.

Der Grund ist nicht Bequemlichkeit: Eine Warnung, die monatelang dasteht
und nichts von einem will, stumpft ab. Man uebersieht die vierte, die
etwas will - und bei § 1878 BGB ist das die Jahrespauschale, die ersatzlos
erlischt.

Ein bestehender Test behauptete "unabhaengig vom Faelligkeitstopf" und
belegte das mit einer Frist 134 Tage in der Zukunft. Die Aussage stimmt
weiterhin, das Beispiel nicht mehr - es liegt jetzt innerhalb des
Horizonts, aber ausserhalb aller Faelligkeitstoepfe.

Gegen drei Mutationen geprueft: Filter entfernt, Abgelaufenes
ausgeschlossen, Horizont um einen Tag verschoben - jede wird rot.

Refs #40
2026-09-01 15:50:17 +02:00
26 changed files with 1766 additions and 64 deletions
+26 -3
View File
@@ -103,12 +103,35 @@ VERTRAUE_PROXY=true
# --- Objektspeicher (S3-kompatibel, EU!) ------------------------------------
# Wird ab Etappe 3 verwendet. Beispiel: Hetzner Object Storage, Falkenstein.
# ACHTUNG: Mit dem Anbieter muss ein AV-Vertrag nach Art. 28 DSGVO bestehen.
S3_ENDPOINT=https://fsn1.your-objectstorage.com
# Bleibt AUSKOMMENTIERT, solange das mitgelieferte MinIO benutzt wird.
#
# Die docker-compose.yml setzt dann von selbst http://minio:9000 - aber
# nur, wenn die Variable leer ist. Steht hier ein Wert, gewinnt er, und
# die Anwendung spricht mit einem fremden Host statt mit dem Speicher
# nebenan. Genau das ist am 31.08.2026 passiert: Jede Ablage scheiterte
# monatelang mit "NoSuchBucket", und die Zugangs-ID ging bei jedem Versuch
# nach draussen.
#
# Nur setzen, wenn ein EXTERNER Objektspeicher benutzt wird, z.B.:
# S3_ENDPOINT=https://fsn1.your-objectstorage.com
S3_REGION=eu-central
S3_BUCKET=betreuungs-cockpit
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
# Serverseitige Verschluesselung durch den Anbieter.
#
# Bleibt leer, und das ist die richtige Voreinstellung: Die Anwendung
# verschluesselt jede Datei selbst, bevor sie den Server verlaesst. Eine
# zweite Lage durch den Anbieter schuetzt vor nichts, was der eigene
# Umschlag nicht schon abdeckt.
#
# Das mitgelieferte MinIO kann sie ohnehin nicht - es laeuft ohne KMS und
# lehnt jede Ablage mit "NotImplemented" ab, sobald hier etwas steht.
#
# Nur setzen, wenn ein externer Anbieter sie ausdruecklich verlangt:
# S3_SSE=AES256
# --- E-Mail-Erinnerungen (optional, ab Etappe 2) ----------------------------
SMTP_HOST=
SMTP_PORT=587
@@ -118,5 +141,5 @@ SMTP_FROM="Betreuungs-Cockpit <cockpit@example.de>"
# --- Erstanmeldung ----------------------------------------------------------
# Wird nur vom Seed benutzt, um den ersten Betreuer-Zugang anzulegen.
SEED_ADMIN_EMAIL=fw@wildeflorian.de
SEED_ADMIN_PASSWORT=bitte-beim-ersten-login-aendern
SEED_ADMIN_EMAIL=
SEED_ADMIN_PASSWORT=
+8
View File
@@ -11,6 +11,14 @@ dist/
next-env.d.ts
*.tsbuildinfo
# Eigene Anpassungen der Installation.
#
# Docker Compose liest diese Datei automatisch zusaetzlich zur
# docker-compose.yml. Sie ist der vorgesehene Ort fuer Aenderungen, die ein
# Update ueberleben sollen: update.sh prueft nur versionierte Dateien
# (git status --untracked-files=no) und laesst sie deshalb in Ruhe.
docker-compose.override.yml
# Umgebung - enthaelt Schluessel, gehoert NIE ins Repo
.env
.env.local
+82 -1
View File
@@ -26,6 +26,86 @@ Wiederherstellung*.
_Noch nichts._
## [1.12.0] – 2026-09-01
Diese Fassung geht aus einer Fehlersuche im laufenden Betrieb hervor. Ein
Schreiben ließ sich nicht in der Akte ablegen — und hinter derselben
Meldung lagen nacheinander drei verschiedene Ursachen. Beim Nachsehen kam
ein vierter Punkt dazu, der schwerer wiegt als alle anderen zusammen.
### Nach dem Update: einmal die Sicherung prüfen
```bash
docker compose up -d
docker compose exec sicherung /bin/sh /sicherung.sh
```
Erwartet werden drei Zeilen: `Datenbank gesichert`, `Dokumente gesichert`
und `Selbstpruefung bestanden - Datenbank und Dokumente.`
### Behoben
- **Deine Dokumente waren in keiner Sicherung.** Der Sicherungsdienst
machte täglich ein `pg_dump` und sonst nichts. Nach einem Plattenschaden
wäre die Akte vollständig wiederhergestellt worden — mit allen
Einträgen, Verweisen und Prüfsummen, aber ohne eine einzige Datei
dahinter. Die Prüfsumme in der Akte hätte dann ein Dokument belegt, das
es nicht mehr gibt.
Je Lauf entstehen jetzt zwei verschlüsselte Dateien mit demselben
Zeitstempel — der Datenbankstand und ein Archiv der Dokumente. Beide mit
Prüfsumme, beide in der Aufbewahrung, beide in der Selbstprüfung.
Schlägt der Dokumententeil fehl, bricht der Lauf ab und räumt den
angefangenen Datenbankstand weg: Eine Sicherung, die nur die halbe
Wahrheit enthält, aber vollständig aussieht, ist schlimmer als gar
keine.
- **Der mitgelieferte Objektspeicher konnte keine Dokumente aufnehmen.**
Die Anwendung verlangte bei jeder Ablage serverseitige Verschlüsselung,
das mitgelieferte MinIO kann sie ohne Zusatzdienst nicht. Wer der
Installationsanleitung folgte, landete zwangsläufig dort. Sie ist jetzt
abschaltbar und standardmäßig aus — gebraucht wird sie nicht, weil die
Anwendung seit 1.11.0 selbst verschlüsselt.
- **„Das Schreiben konnte nicht abgelegt werden" sagte nichts.** Jetzt
steht dort, was los ist: fehlender Ablagebereich mit Verweis auf den
Installationsschritt, abgelehnte Zugangsdaten, nicht erreichbarer
Speicher. Und in jedem Fall, ob deine Eingaben erhalten bleiben.
- **Zwei Einstellungen erreichten die Anwendung nie.**
`ERSTEINRICHTUNG_GEHEIMNIS` und `VERTRAUE_PROXY` standen zwar in der
Anleitung, wurden aber nicht an den Behälter durchgereicht. Bei
`VERTRAUE_PROXY` fiel das nicht auf: Die Anmeldesperre zählte still eine
Dimension weniger.
- **Die Beispielkonfiguration zeigte auf einen fremden Anbieter.**
`S3_ENDPOINT` war mit einer externen Adresse vorbelegt und hebelte damit
den richtigen Standard aus. Die Zeile ist jetzt auskommentiert.
### Neu
- **Die Anwendung prüft ihre Dateiablage selbst.** Sie schreibt eine
winzige Datei, liest sie zurück und räumt sie weg — und beantwortet
damit auf einmal, ob der Speicher erreichbar ist, der Ablagebereich
existiert und geschrieben werden darf. Das Ergebnis steht unter
`/api/gesundheit` und im Log. Bisher fiel eine kaputte Konfiguration
erst auf, wenn jemand ein Dokument ablegen wollte.
- **Ein neuer Abschnitt in der Anleitung: „Wo deine Daten liegen".**
Die drei Orte, der Pfad auf der Platte, welche Schlüssel unverzichtbar
sind — und wie man prüft, ob zu jedem Akteneintrag auch eine Datei
gehört.
### Geändert
- **Ausschlussfristen warnen nur noch, wenn sie näherrücken.** Auf dem
Dashboard standen dauerhaft drei rote Kästen mit Warndreieck — für
Aufwandspauschalen, die 2027, 2028 und 2029 ablaufen. Eine Warnung, die
monatelang dasteht und nichts von einem will, stumpft ab; man übersieht
die vierte, die etwas will. Ab drei Monaten vor Ablauf meldet sich eine
Frist wieder, Versäumtes immer. In der Fristenliste stehen sie
unverändert weiter.
## [1.11.0] – 2026-08-24
Diese Fassung geht aus einer Sicherheitsprüfung hervor. Sie ändert nichts
@@ -1265,7 +1345,8 @@ Was davon wann angegangen wird, steht in [`docs/ROADMAP.md`](docs/ROADMAP.md).
---
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.11.0...HEAD
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.12.0...HEAD
[1.12.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.11.0...v1.12.0
[1.11.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.10.2...v1.11.0
[1.10.2]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.10.1...v1.10.2
[1.10.1]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.10.0...v1.10.1
+23
View File
@@ -93,6 +93,15 @@ services:
AUTH_SECRET: ${AUTH_SECRET:?AUTH_SECRET fehlt in der .env}
AUTH_URL: ${AUTH_URL:?AUTH_URL fehlt in der .env}
AUTH_TRUST_HOST: 'true'
# Ohne diesen Wert laesst sich der erste Zugang nicht anlegen - siehe
# src/server/actions/ersteinrichtung.ts. Leer heisst: gesperrt.
ERSTEINRICHTUNG_GEHEIMNIS: ${ERSTEINRICHTUNG_GEHEIMNIS:-}
# Bewusst OHNE Vorgabe "true": Die strengere Einstellung bleibt die
# Voreinstellung. Wer einen Reverse Proxy davorstellt - der
# dokumentierte Betrieb tut das -, sagt es in der .env ausdruecklich.
# Ohne den Wert zaehlt die Anmeldesperre nur nach E-Mail-Adresse.
VERTRAUE_PROXY: ${VERTRAUE_PROXY:-}
VERTRAUE_PROXY_STUFEN: ${VERTRAUE_PROXY_STUFEN:-}
ENC_KEY_V1: ${ENC_KEY_V1:?ENC_KEY_V1 fehlt in der .env - ohne ihn sind die verschluesselten Felder nicht lesbar}
# Bleibt leer, solange nicht rotiert wird. Die Anwendung ueberspringt
# leere Schluesselvariablen - siehe src/lib/env.ts.
@@ -110,6 +119,9 @@ services:
S3_BUCKET: ${S3_BUCKET:-betreuungs-cockpit}
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY}
# Leer lassen. Das mitgelieferte MinIO laeuft ohne KMS und lehnt jede
# Ablage ab, sobald hier etwas steht - siehe .env.example.
S3_SSE: ${S3_SSE:-}
UPLOAD_MAX_BYTES: ${UPLOAD_MAX_BYTES:-26214400}
OCR_AKTIV: ${OCR_AKTIV:-true}
SMTP_HOST: ${SMTP_HOST:-}
@@ -155,9 +167,20 @@ services:
PGDATABASE: ${POSTGRES_DB:-betreuungs_cockpit}
SICHERUNG_PASSWORT: ${SICHERUNG_PASSWORT:?SICHERUNG_PASSWORT fehlt in der .env}
SICHERUNG_AUFBEWAHRUNG_TAGE: ${SICHERUNG_AUFBEWAHRUNG_TAGE:-30}
SICHERUNG_DOKUMENTE_QUELLE: /dokumente
TZ: Europe/Berlin
volumes:
- ./backups:/backups
# Die Dokumente, ausdruecklich NUR LESEND.
#
# Der Sicherungsdienst laeuft unbeaufsichtigt per cron. Ein Dienst,
# der die Daten zerstoeren kann, die er sichern soll, ist ein Risiko
# ohne Gegenwert - ein Fehler im Skript oder ein kompromittierter
# Behaelter wuerde beides auf einmal treffen.
#
# Zurueckspielen geschieht deshalb nicht hierher, sondern in ein
# Verzeichnis unter ./backups; siehe scripts/wiederherstellung.sh.
- minio-daten:/dokumente:ro
- ./scripts/sicherung.sh:/sicherung.sh:ro
- ./scripts/wiederherstellung.sh:/wiederherstellung.sh:ro
entrypoint: ['/bin/sh', '-c']
+199 -20
View File
@@ -34,6 +34,7 @@ durchgeht — dann weißt du im Fehlerfall genau, wo es hakt.
- [Schritt 9 — Sicherung prüfen](#schritt-9--sicherung-prüfen)
- [Schritt 10 — Wartungslauf einrichten](#schritt-10--wartungslauf-einrichten)
- [Schritt 11 — Schlüssel in Sicherheit bringen](#schritt-11--schlüssel-in-sicherheit-bringen)
- [Wo deine Daten liegen](#wo-deine-daten-liegen)
- [Aktualisieren](#aktualisieren)
- [Wenn etwas nicht klappt](#wenn-etwas-nicht-klappt)
- [Abnahmeliste](#abnahmeliste)
@@ -184,6 +185,13 @@ cp .env.example .env
nano .env
```
> **Die `.env` allein genügt nicht.** Die `docker-compose.yml` reicht die
> Werte einzeln an den Behälter durch — sie hat kein `env_file:`. Ein
> selbst ergänzter Wert, der dort nicht aufgeführt ist, erreicht die
> Anwendung nicht, auch wenn er in der `.env` steht. Alle hier
> beschriebenen Werte sind eingetragen; ein Test wacht darüber
> (`tests/umgebung-durchreichen.test.ts`).
Diese Werte **musst** du setzen:
```bash
@@ -211,6 +219,9 @@ INDEX_KEY=<aus Schritt 2>
# --- Objektspeicher ---
S3_ACCESS_KEY_ID=<aus Schritt 2>
S3_SECRET_ACCESS_KEY=<aus Schritt 2>
# S3_ENDPOINT bleibt AUSKOMMENTIERT, solange du das mitgelieferte MinIO
# benutzt. Nur dann setzt die docker-compose.yml von selbst
# http://minio:9000 - steht dort ein Wert, gewinnt er.
# --- Sicherung ---
SICHERUNG_PASSWORT=<aus Schritt 2>
@@ -312,7 +323,8 @@ betreuungs-cockpit-sicherung-1 Up
```bash
# Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast)
curl -s http://127.0.0.1:3000/api/gesundheit
# {"status":"bereit","version":"1.10.2","commit":"..."}
# {"status":"bereit","version":"1.12.0","commit":"...",
# "speicher":{"status":"bereit","treiber":"s3"}}
```
Kommt hier nichts, sieh in die Logs:
@@ -364,20 +376,42 @@ nichts kaputtmacht.
Der Objektspeicher braucht einen Ablagebereich für die Dokumente:
```bash
set -a && source .env && set +a
docker compose exec minio mc alias set lokal \
http://localhost:9000 "$S3_ACCESS_KEY_ID" "$S3_SECRET_ACCESS_KEY"
docker compose exec minio mc mb lokal/betreuungs-cockpit
docker compose exec minio sh -c '
mc alias set lokal http://localhost:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null &&
mc mb "lokal/$S3_BUCKET" 2>/dev/null || true
mc ls lokal
'
```
### Kontrolle
Es muss eine Zeile mit dem Namen deines Ablagebereichs erscheinen —
standardmäßig `betreuungs-cockpit/`.
```bash
docker compose exec minio mc ls lokal
# [...] betreuungs-cockpit/
```
> **Warum der Befehl so umständlich aussieht.** Er holt die Zugangsdaten
> aus den Variablen, die im MinIO-Behälter ohnehin stehen. Das hat drei
> Gründe, und jeder davon hat schon einmal Zeit gekostet:
>
> 1. **Kein Passwort in der Befehlszeile** — es stünde sonst in der
> Verlaufsdatei deiner Shell.
> 2. **Kein `source .env`** — exportierte Werte haben Vorrang vor der
> `.env`-Datei und überstimmen danach *jedes* `docker compose up`. Wer
> später die `.env` ändert und neu startet, sieht keine Wirkung und
> sucht den Fehler überall, nur nicht in der eigenen Shell.
> 3. **Der Alias überlebt kein Neuerstellen.** `mc alias set` schreibt
> nach `/root/.mc` **im Behälter**. Nach jedem `docker compose up -d
> minio` ist er weg, und `mc ls lokal` liefert dann kommentarlos nichts
> — man hält seine Dokumente für verschwunden. Deshalb setzt jeder
> Aufruf den Alias selbst.
>
> Der Bucket-Name kommt aus `$S3_BUCKET` und ist nicht fest verdrahtet:
> Sonst zeigen Anwendung und Ablagebereich aneinander vorbei, sobald
> jemand den Namen ändert.
> **Serverseitige Verschlüsselung?** Brauchst du nicht. Die Anwendung
> verschlüsselt jede Datei selbst, bevor sie den Server verlässt — mit
> `ENC_KEY_V1`, der den Anbieter nie erreicht. Das mitgelieferte MinIO
> läuft ohne KMS und würde jede Ablage ablehnen, sobald `S3_SSE` in der
> `.env` steht. Setz den Wert nur, wenn ein externer Anbieter ihn
> ausdrücklich verlangt.
> **Externer Objektspeicher statt MinIO?** Wenn du einen S3-Dienst in der
> EU benutzt (z. B. Hetzner Object Storage, Falkenstein), streich den
@@ -388,6 +422,21 @@ docker compose exec minio mc ls lokal
---
> **Der Abschnitt `speicher`** sagt, ob die Dateiablage benutzbar ist:
> erreichbar, Ablagebereich vorhanden, Schreibrecht da. Steht dort
> `gestoert`, lässt sich kein Dokument ablegen — alles andere funktioniert
> weiter. Die Erklärung steht im Log:
>
> ```bash
> docker compose logs app | grep '\[speicher\]'
> ```
>
> Geprüft wird beim ersten Aufruf dieser Adresse und danach höchstens alle
> fünf Minuten. Der Healthcheck ruft sie ohnehin auf, die Zeile steht also
> kurz nach dem Start im Log.
---
## Schritt 7 — In den Reverse Proxy eintragen
Der Proxy steht auf einem **anderen Server**. Damit sind zwei Dinge zu
@@ -731,13 +780,19 @@ Erwartete Ausgabe, in dieser Reihenfolge:
```
Sicherung beginnt: /backups/cockpit_2026-08-22_1430.dump.enc
Sicherung fertig (1.2M)
Selbstpruefung bestanden.
Datenbank gesichert (1.2M)
Dokumente gesichert (18M)
Selbstpruefung bestanden - Datenbank und Dokumente.
```
Die **Selbstprüfung** ist der eigentliche Punkt: Das Skript entschlüsselt
die eben geschriebene Datei sofort wieder. Schlägt das fehl, endet der
Lauf mit einem Fehler statt mit einem stillen Scheinerfolg.
beide Dateien sofort wieder und liest sie einmal durch. Schlägt das fehl,
endet der Lauf mit einem Fehler statt mit einem stillen Scheinerfolg.
Fehlt die Zeile `Dokumente gesichert`, ist das Ablageverzeichnis nicht in
den Sicherungsdienst eingehängt. Das Skript bricht dann ab und räumt den
angefangenen Datenbankstand wieder weg — eine Sicherung, die nur die halbe
Wahrheit enthält, aber vollständig aussieht, wäre schlimmer als gar keine.
### Und jetzt einmal zurückspielen
@@ -760,6 +815,25 @@ docker compose exec db psql -U cockpit probe \
docker compose exec db dropdb -U cockpit probe
```
Beim Zurückspielen packt das Skript auch das Dokumentenarchiv aus — nach
`backups/dokumente-wiederhergestellt_<datum>/`. An ihren Platz schreibt es
die Dateien **nicht**: Der Sicherungsdienst hat auf das Ablageverzeichnis
bewusst nur Lesezugriff, und in ein laufendes MinIO hineinzuschreiben würde
dessen Datenbestand beschädigen. Wie der letzte Handgriff aussieht, gibt
das Skript am Ende selbst aus.
Für die Übung genügt ein Blick in das ausgepackte Verzeichnis:
```bash
find backups/dokumente-wiederhergestellt_*/ -type f | head
rm -rf backups/dokumente-wiederhergestellt_*
```
> **Im Ernstfall gehört beides zusammen.** Eine Datenbank ohne die
> zugehörigen Dateien ergibt eine Akte, deren Prüfsummen Dokumente
> belegen, die es nicht mehr gibt. Spiel immer das Paar mit demselben
> Zeitstempel zurück.
### Kontrolle
```bash
@@ -767,12 +841,18 @@ ls -lh backups/
# cockpit_....dump.enc und cockpit_....dump.enc.sha256
```
> **Was das Backup nicht enthält:** den Schlüssel `ENC_KEY_V1` und die
> Dateien im Objektspeicher. Für die Dateien:
> **Was das Backup enthält:** die Datenbank *und* die Dokumente. Je Lauf
> entstehen zwei verschlüsselte Dateien mit demselben Zeitstempel:
>
> ```bash
> docker compose exec minio mc mirror lokal/betreuungs-cockpit /backups/dokumente
> ```
> cockpit_2026-09-01_0315.dump.enc <- Akte, Fristen, Buchungen
> dokumente_2026-09-01_0315.tar.gz.enc <- Bescheide, Schreiben, Belege
> ```
>
> **Was es nicht enthält:** den Schlüssel `ENC_KEY_V1`. Ohne ihn bleiben
> auch aus einer vollständig zurückgespielten Sicherung die Diagnosen,
> IBANs, Notizen und Dokumente unlesbar. Er gehört an die zwei Orte aus
> Schritt 11.
>
> Und kopier die Sicherungen regelmäßig **weg vom Server** — ein Backup,
> das neben dem Original liegt, hilft bei einem Serverausfall nicht.
@@ -849,6 +929,105 @@ Denk an die letzten Pflichten:
---
## Wo deine Daten liegen
Drei Orte, und keiner davon ist ein Ordner, den du einfach öffnest.
| Was | Wo | In der täglichen Sicherung? |
|---|---|---|
| **Dokumente** (Bescheide, Schreiben, Belege) | Docker-Volume `minio-daten` | ja |
| **Datenbank** (Akte, Fristen, Buchungen) | Docker-Volume `postgres-daten` | ja |
| **Sicherungen** | `./backups` neben der `docker-compose.yml` | — |
### Den Pfad auf der Platte finden
```bash
docker volume inspect betreuungs-cockpit_minio-daten --format '{{.Mountpoint}}'
# /var/lib/docker/volumes/betreuungs-cockpit_minio-daten/_data
```
Heißt dein Projektverzeichnis anders, heißen auch die Volumes anders —
Docker stellt den Verzeichnisnamen voran:
```bash
docker volume ls | grep -E "minio-daten|postgres-daten"
```
> **Dort hineinzusehen bringt nichts.** MinIO legt jedes Dokument als
> eigenes Verzeichnis mit Metadatei und Datenteilen ab, und der Inhalt ist
> verschlüsselt. Du fändest dort keine anklickbaren PDFs, sondern
> Fragmente. Wer die Dokumente sehen will, nimmt die Anwendung.
### Nachsehen, was abgelegt ist
```bash
docker compose exec minio sh -c '
mc alias set lokal http://localhost:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null &&
mc ls --recursive "lokal/$S3_BUCKET"
'
```
Je Dokument eine Zeile unter `betreute/<id>/dokumente/<uuid>.pdf`.
### Prüfen, ob Akte und Speicher übereinstimmen
Die wichtigste Kontrolle: Zu **jedem** Eintrag in der Akte muss eine Datei
gehören. Fehlt sie, belegt eine Prüfsumme ein Dokument, das es nicht mehr
gibt — gegenüber dem Betreuungsgericht der schlechteste Zustand.
```bash
docker compose exec -T db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At -c "SELECT \"objektSchluessel\" FROM dokument WHERE \"geloeschtAm\" IS NULL ORDER BY 1;"' | sort > /tmp/erwartet.txt
docker compose exec minio sh -c '
mc alias set lokal http://localhost:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null &&
mc ls --recursive "lokal/$S3_BUCKET"
' | awk '{print $NF}' | sort > /tmp/vorhanden.txt
echo "In der Akte: $(wc -l < /tmp/erwartet.txt) Im Speicher: $(wc -l < /tmp/vorhanden.txt)"
echo "--- Fehlt im Speicher (Handlungsbedarf): ---"
comm -23 /tmp/erwartet.txt /tmp/vorhanden.txt
echo "--- Verwaist im Speicher (harmlos): ---"
comm -13 /tmp/erwartet.txt /tmp/vorhanden.txt
```
Beide Zahlen gleich und beide Listen leer — dann passt es.
Ein Eintrag unter *„Fehlt im Speicher"* ist ein Akteneintrag ohne Datei.
Er entsteht, wenn eine Ablage scheitert. Solche Einträge löschen und das
Dokument neu anlegen.
### Ohne welche Schlüssel nichts davon lesbar ist
| Schlüssel | Ohne ihn |
|---|---|
| `ENC_KEY_V1` | Diagnosen, IBANs, Notizen und alle Dokumente bleiben unlesbar — auch aus jeder Sicherung |
| `SICHERUNG_PASSWORT` | keine Sicherung lässt sich öffnen |
| `MINIO_KMS_SECRET_KEY` | nur falls gesetzt: MinIO gibt die Objekte nicht mehr heraus |
Alle gehören an die zwei Orte aus Schritt 11, und keiner davon ist dieser
Server.
### Eigene Anpassungen, die ein Update überleben
Änderungen an der `docker-compose.yml` legt `scripts/update.sh` bei jedem
Update per `git stash` beiseite — sie sind versioniert. Wer etwas dauerhaft
ändern will, legt stattdessen eine **`docker-compose.override.yml`**
daneben:
```yaml
services:
app:
environment:
EIGENER_WERT: ${EIGENER_WERT:-}
```
Docker Compose liest sie automatisch mit. `update.sh` prüft nur
versionierte Dateien (`git status --untracked-files=no`) und lässt sie
deshalb unangetastet.
---
## Aktualisieren
```bash
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "betreuungs-cockpit",
"version": "1.11.0",
"version": "1.12.0",
"private": true,
"description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung",
"engines": {
+81 -18
View File
@@ -1,6 +1,6 @@
#!/bin/sh
# =============================================================================
# Taegliche Sicherung der Datenbank
# Taegliche Sicherung: Datenbank UND Dokumente
# =============================================================================
#
# Laeuft im Behaelter "sicherung" per cron um 03:15 Uhr. Von Hand:
@@ -10,17 +10,44 @@
# Was passiert:
# 1. pg_dump im eigenen Format (-Fc), damit einzelne Tabellen
# wiederherstellbar bleiben.
# 2. Verschluesselung mit AES-256 und PBKDF2 aus SICHERUNG_PASSWORT.
# 2. Die abgelegten Dokumente als Archiv.
# 3. Beides verschluesselt mit AES-256 und PBKDF2 aus SICHERUNG_PASSWORT.
# Das Backup liegt oft auf fremdem Speicher - unverschluesselt waere
# es ein Datenleck mit Ansage.
# 3. SHA-256-Pruefsumme daneben.
# 4. Staende, die aelter sind als SICHERUNG_AUFBEWAHRUNG_TAGE, entfernen.
# 4. SHA-256-Pruefsumme je Datei.
# 5. Staende, die aelter sind als SICHERUNG_AUFBEWAHRUNG_TAGE, entfernen.
# Der jeweils erste Stand eines Monats bleibt stehen.
#
# WICHTIG: Dieses Backup enthaelt die verschluesselten Felder, aber NICHT
# den Schluessel ENC_KEY_V1. Ohne ihn sind Diagnosen, IBANs und Notizen
# auch aus einer wiederhergestellten Datenbank nicht lesbar. Den Schluessel
# getrennt sichern - Passwortmanager oder ausgedruckt im Ordner.
# ## Warum die Dokumente dazugehoeren
#
# Bis v1.11.0 sicherte dieses Skript nur die Datenbank. Nach einem
# Plattenschaden waere die Akte vollstaendig wiederhergestellt worden -
# mit allen Eintraegen, Verweisen und Pruefsummen, aber ohne eine einzige
# Datei dahinter. Die Pruefsumme in der Akte haette dann ein Dokument
# belegt, das es nicht mehr gibt. Gegenueber dem Betreuungsgericht ist das
# der schlechteste denkbare Zustand.
#
# ## Warum das Ablageverzeichnis roh eingepackt wird
#
# Der naheliegende Weg waere "mc mirror" aus dem MinIO-Client. Der ist in
# diesem Behaelter aber nicht vorhanden und muesste bei jedem Lauf aus dem
# Netz geholt werden - eine Internetabhaengigkeit im Sicherungspfad ist
# genau das, was man dort nicht will.
#
# Stattdessen wird das Datenverzeichnis direkt eingepackt. Das ist
# vertretbar, weil MinIO ein Objekt erst vollstaendig unter .minio.sys/tmp
# schreibt und dann an seinen Platz umbenennt: Ein laufender Upload liegt
# also nie halbfertig im Archiv, er fehlt hoechstens. Bei einem Lauf um
# 03:15 Uhr auf einem Einzelplatzsystem ist auch das unwahrscheinlich.
#
# Derselbe Weg deckt den Dateitreiber mit ab - dort liegen die Dateien
# ebenfalls einfach im Verzeichnis.
#
# WICHTIG: Dieses Backup enthaelt die verschluesselten Felder und die
# verschluesselten Dateien, aber NICHT den Schluessel ENC_KEY_V1. Ohne ihn
# sind Diagnosen, IBANs, Notizen und Dokumente auch aus einer
# wiederhergestellten Sicherung nicht lesbar. Den Schluessel getrennt
# sichern - Passwortmanager oder ausgedruckt im Ordner.
# =============================================================================
set -eu
@@ -30,6 +57,11 @@ AUFBEWAHRUNG="${SICHERUNG_AUFBEWAHRUNG_TAGE:-30}"
ZEITSTEMPEL="$(date +%Y-%m-%d_%H%M)"
TAG_IM_MONAT="$(date +%d)"
DATEI="$ZIEL/cockpit_${ZEITSTEMPEL}.dump.enc"
DOKUMENTE_DATEI="$ZIEL/dokumente_${ZEITSTEMPEL}.tar.gz.enc"
# Wo die Dokumente liegen. Der Behaelter bekommt das Ablageverzeichnis
# eingehaengt - siehe docker-compose.yml, Dienst "sicherung".
DOKUMENTE_QUELLE="${SICHERUNG_DOKUMENTE_QUELLE:-/dokumente}"
if [ -z "${SICHERUNG_PASSWORT:-}" ]; then
echo "$(date -Iseconds) FEHLER: SICHERUNG_PASSWORT ist nicht gesetzt. Abbruch." >&2
@@ -50,17 +82,39 @@ pg_dump --format=custom --compress=6 --no-owner --no-privileges \
GROESSE="$(du -h "$DATEI" | cut -f1)"
sha256sum "$DATEI" > "$DATEI.sha256"
# Der jeweils erste Stand eines Monats bekommt eine Markierung und bleibt
# beim Aufraeumen verschont.
if [ "$TAG_IM_MONAT" = "01" ]; then
touch "$DATEI.monatsstand"
echo "$(date -Iseconds) Datenbank gesichert ($GROESSE)"
# --- Dokumente ---------------------------------------------------------------
# Schlaegt dieser Teil fehl, bricht der ganze Lauf ab (set -e). Das ist
# Absicht: Eine Sicherung, die nur die halbe Wahrheit enthaelt, aber
# vollstaendig aussieht, ist schlimmer als gar keine - man verlaesst sich
# darauf und merkt es erst im Ernstfall.
if [ ! -d "$DOKUMENTE_QUELLE" ]; then
echo "$(date -Iseconds) FEHLER: Das Ablageverzeichnis $DOKUMENTE_QUELLE ist nicht eingehaengt." >&2
echo " Ohne die Dokumente ist die Sicherung unvollstaendig." >&2
echo " Siehe docker-compose.yml, Dienst \"sicherung\"." >&2
rm -f "$DATEI" "$DATEI.sha256"
exit 1
fi
echo "$(date -Iseconds) Sicherung fertig ($GROESSE)"
# "tar" ohne Leitung an einen Verbraucher, der frueh schliesst - sonst
# SIGPIPE, siehe die Anmerkung zur Selbstpruefung weiter unten.
tar -czf - -C "$DOKUMENTE_QUELLE" . | openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -salt -pass env:SICHERUNG_PASSWORT -out "$DOKUMENTE_DATEI"
DOK_GROESSE="$(du -h "$DOKUMENTE_DATEI" | cut -f1)"
sha256sum "$DOKUMENTE_DATEI" > "$DOKUMENTE_DATEI.sha256"
echo "$(date -Iseconds) Dokumente gesichert ($DOK_GROESSE)"
# Der jeweils erste Stand eines Monats bekommt eine Markierung und bleibt
# beim Aufraeumen verschont. Beide Teile, sonst faellt die Haelfte weg.
if [ "$TAG_IM_MONAT" = "01" ]; then
touch "$DATEI.monatsstand" "$DOKUMENTE_DATEI.monatsstand"
fi
# --- Aufraeumen --------------------------------------------------------------
ENTFERNT=0
for alt in "$ZIEL"/cockpit_*.dump.enc; do
for alt in "$ZIEL"/cockpit_*.dump.enc "$ZIEL"/dokumente_*.tar.gz.enc; do
[ -e "$alt" ] || continue
[ -e "$alt.monatsstand" ] && continue
@@ -87,9 +141,18 @@ KOPF="$(openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
-pass env:SICHERUNG_PASSWORT -in "$DATEI" 2>/dev/null \
| dd bs=1 count=5 2>/dev/null || true)"
if [ "$KOPF" = "PGDMP" ]; then
echo "$(date -Iseconds) Selbstpruefung bestanden."
else
echo "$(date -Iseconds) FEHLER: Die Sicherung laesst sich nicht entschluesseln!" >&2
if [ "$KOPF" != "PGDMP" ]; then
echo "$(date -Iseconds) FEHLER: Der Datenbankstand laesst sich nicht entschluesseln!" >&2
exit 1
fi
# Dasselbe fuer die Dokumente: entschluesseln und das Archiv auflisten
# lassen. "tar -tzf" liest die ganze Datei und meldet jeden Schaden - das
# ist bei den Groessenordnungen hier bezahlbar und sagt mehr aus als ein
# Blick auf die ersten Bytes.
if ! openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 -pass env:SICHERUNG_PASSWORT -in "$DOKUMENTE_DATEI" 2>/dev/null | tar -tzf - >/dev/null 2>&1; then
echo "$(date -Iseconds) FEHLER: Das Dokumentenarchiv laesst sich nicht lesen!" >&2
exit 1
fi
echo "$(date -Iseconds) Selbstpruefung bestanden - Datenbank und Dokumente."
+64 -4
View File
@@ -9,7 +9,7 @@
# ausdruecklich nach, weil dabei der aktuelle Datenbestand ueberschrieben
# wird.
#
# ZWEI DINGE, DIE MAN VORHER WISSEN MUSS:
# DREI DINGE, DIE MAN VORHER WISSEN MUSS:
#
# 1. Die Sicherung enthaelt die anwendungsseitig verschluesselten Felder,
# aber NICHT den Schluessel. Ohne den passenden ENC_KEY_Vn aus der .env
@@ -20,6 +20,14 @@
# das hier einmal im Jahr auf einer Testdatenbank aus, bevor du es im
# Ernstfall zum ersten Mal brauchst. Siehe README, Abschnitt
# "Wiederherstellung ueben".
#
# 3. Die Dokumente werden NICHT selbsttaetig an ihren Platz geschrieben.
# Dieses Skript packt sie nach ./backups aus und sagt, wie es
# weitergeht. Grund: Der Sicherungsdienst hat auf das Ablageverzeichnis
# bewusst nur Lesezugriff (siehe docker-compose.yml), und in ein
# laufendes MinIO hineinzuschreiben wuerde dessen Datenbestand
# beschaedigen. Der letzte Handgriff bleibt beim Menschen - er ist ein
# Einzeiler und im Klartext beschrieben.
# =============================================================================
set -eu
@@ -87,7 +95,44 @@ openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
| pg_restore --clean --if-exists --no-owner --no-privileges \
--dbname="${PGDATABASE:-postgres}" --exit-on-error
echo "$(date -Iseconds) Wiederherstellung abgeschlossen."
echo "$(date -Iseconds) Datenbank wiederhergestellt."
# --- Dokumente ---------------------------------------------------------------
# Zu "cockpit_2026-09-01_0315.dump.enc" gehoert
# "dokumente_2026-09-01_0315.tar.gz.enc" - gleicher Zeitstempel, anderer
# Name. Fehlt sie, stammt die Sicherung aus einer Fassung vor v1.12.0.
ZEITSTEMPEL="$(basename "$DATEI" | sed -n 's/^cockpit_\(.*\)\.dump\.enc$/\1/p')"
DOKUMENTE_DATEI="$(dirname "$DATEI")/dokumente_${ZEITSTEMPEL}.tar.gz.enc"
AUSPACKZIEL="$(dirname "$DATEI")/dokumente-wiederhergestellt_${ZEITSTEMPEL}"
if [ ! -f "$DOKUMENTE_DATEI" ]; then
echo ""
echo "WARNUNG: Zu dieser Sicherung gibt es kein Dokumentenarchiv."
echo " Erwartet wurde: $DOKUMENTE_DATEI"
echo " Sicherungen aus Fassungen vor v1.12.0 enthalten nur die"
echo " Datenbank. Die Akte ist dann vollstaendig, die Dateien"
echo " dahinter fehlen."
else
if [ -f "$DOKUMENTE_DATEI.sha256" ]; then
echo "Pruefsumme des Dokumentenarchivs wird geprueft ..."
if sha256sum -c "$DOKUMENTE_DATEI.sha256" >/dev/null 2>&1; then
echo " in Ordnung."
else
echo "FEHLER: Die Pruefsumme des Dokumentenarchivs stimmt nicht." >&2
exit 1
fi
fi
mkdir -p "$AUSPACKZIEL"
openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
-pass env:SICHERUNG_PASSWORT -in "$DOKUMENTE_DATEI" \
| tar -xzf - -C "$AUSPACKZIEL"
ANZAHL="$(find "$AUSPACKZIEL" -type f | wc -l)"
echo "$(date -Iseconds) Dokumente ausgepackt: $ANZAHL Dateien nach"
echo " $AUSPACKZIEL"
fi
echo ""
echo "Naechste Schritte:"
echo " 1. Die SQL-Skripte erneut ausfuehren (Volltextsuche und Audit-Schutz):"
@@ -95,6 +140,21 @@ echo " docker compose exec app npm run db:sql"
echo " 2. Pruefen, ob ENC_KEY_V1 in der .env zu diesem Bestand passt -"
echo " ohne den richtigen Schluessel bleiben die verschluesselten"
echo " Felder unlesbar."
echo " 3. In der Anwendung eine betreute Person oeffnen und nachsehen, ob"
echo " die Diagnosen im Klartext erscheinen. Erst dann ist die"
echo " 3. Die Dokumente an ihren Platz bringen. Dazu MinIO anhalten, das"
echo " Datenverzeichnis ersetzen und wieder starten - auf dem Host,"
echo " nicht in diesem Behaelter:"
echo ""
echo " docker compose stop minio app"
echo " docker run --rm \\"
echo " -v betreuungs-cockpit_minio-daten:/ziel \\"
echo " -v \"\$PWD/backups:/quelle:ro\" alpine \\"
echo " sh -c 'rm -rf /ziel/* /ziel/..?* && cp -a /quelle/dokumente-wiederhergestellt_*/. /ziel/'"
echo " docker compose up -d"
echo ""
echo " Steht mehr als ein ausgepacktes Verzeichnis unter ./backups,"
echo " vorher das richtige auswaehlen statt des Sternchens."
echo ""
echo " 4. In der Anwendung eine betreute Person oeffnen und nachsehen, ob"
echo " die Diagnosen im Klartext erscheinen. Dann ein Dokument"
echo " herunterladen. Erst wenn beides klappt, ist die"
echo " Wiederherstellung wirklich gelungen."
+26 -5
View File
@@ -1,12 +1,18 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { COMMIT, VERSION } from '@/lib/version'
import { speicherZustand } from '@/lib/speicher/pruefung'
/**
* Betriebsbereitschaft fuer den Healthcheck in docker-compose.
*
* Bewusst ohne Anmeldung, aber ohne jede Auskunft ueber Inhalte: geprueft
* wird nur, ob die Datenbank antwortet.
* wird, ob Datenbank und Dateiablage antworten.
*
* Nur die Datenbank entscheidet ueber 503. Ein gestoerter Speicher wird
* gemeldet, laesst den Behaelter aber laufen - sonst wuerde der
* Healthcheck ihn neu starten, und die ganze Akte waere weg wegen eines
* Teils, den ein Neustart nicht repariert.
*
* Die Version steht dabei: sie wird beim Aktualisieren gebraucht, um zu
* sehen, ob der neue Stand tatsaechlich laeuft. Das ist keine Auskunft
@@ -19,14 +25,29 @@ export const dynamic = 'force-dynamic'
export async function GET() {
try {
await db.$queryRaw`SELECT 1`
return NextResponse.json(
{ status: 'bereit', version: VERSION, commit: COMMIT || undefined },
{ headers: { 'Cache-Control': 'no-store' } },
)
} catch {
return NextResponse.json(
{ status: 'datenbank nicht erreichbar', version: VERSION },
{ status: 503, headers: { 'Cache-Control': 'no-store' } },
)
}
// Der Speicher wird mitgeprueft, aber er entscheidet NICHT ueber die
// Betriebsbereitschaft: Faellt er aus, funktioniert alles ausser dem
// Ablegen von Dateien. Ein 503 wuerde den Behaelter neu starten lassen
// und die ganze Akte unerreichbar machen - wegen eines Teils, den ein
// Neustart nicht repariert.
const speicher = await speicherZustand()
return NextResponse.json(
{
status: 'bereit',
version: VERSION,
commit: COMMIT || undefined,
speicher: speicher.ok
? { status: 'bereit', treiber: speicher.treiber }
: { status: 'gestoert', treiber: speicher.treiber, art: speicher.art },
},
{ headers: { 'Cache-Control': 'no-store' } },
)
}
+37 -1
View File
@@ -24,6 +24,42 @@ export const WARTEZEIT_DRINGEND_TAGE = 21
/** Wie viele Ausschlussfristen zeigt die Warnung hoechstens? */
const AUSSCHLUSSFRISTEN_MAX = 3
/**
* Wie weit im Voraus eine Ausschlussfrist die Warnung ausloest.
*
* Frueher gab es diese Grenze nicht: Der Warnblock fragte nur, OB etwas
* eine Ausschlussfrist ist, nie WANN sie faellig wird. Auf dem Dashboard
* standen deshalb dauerhaft drei rote Kaesten mit Warndreieck - fuer
* Aufwandspauschalen, die 2027, 2028 und 2029 ablaufen. Die Pflichtfristen
* werden bewusst drei Jahre im Voraus angelegt (jahreVoraus in
* pflichtfristen.ts), und alle landeten ungefiltert in der Warnung.
*
* Das ist nicht nur laestig, sondern gefaehrlich: Eine Warnung, die
* monatelang dasteht und nichts von einem will, stumpft ab. Man uebersieht
* die vierte, die etwas will - und bei § 1878 BGB ist das die
* Jahrespauschale, die ersatzlos erlischt.
*
* 90 Tage, weil die kuerzeste hier vorkommende Ausschlussfrist sechs
* Monate Zeit laesst. Drei Monate Vorlauf sind reichlich, und solange
* nichts ansteht, bleibt der Kasten leer - dann bedeutet er etwas.
*
* Verschwinden tut nichts: Was ausserhalb des Horizonts liegt, steht
* weiterhin in der Fristenliste.
*/
const AUSSCHLUSSFRIST_HORIZONT_TAGE = 90
/**
* Gehoert diese Frist in den hervorgehobenen Warnblock?
*
* Abgelaufenes immer - eine versaeumte Ausschlussfrist bleibt eine
* schlechte Nachricht, auch wenn sie lange zurueckliegt. Sonst nur, was
* innerhalb des Horizonts faellig wird.
*/
function warntJetzt(eintrag: { ausschlussfrist: boolean; tage: number }): boolean {
if (!eintrag.ausschlussfrist) return false
return eintrag.tage < 0 || eintrag.tage <= AUSSCHLUSSFRIST_HORIZONT_TAGE
}
/**
* Datumsfelder ohne Uhrzeit (@db.Date) kommen als UTC-Mitternacht aus der
* Datenbank. Wer sie unbesehen mit einem lokalen "heute" vergleicht,
@@ -126,7 +162,7 @@ export function fristenEinordnen(fristen: FristRoh[], heute: Date): FristenAmpel
dieseWoche: eintraege.filter((e) => e.stufe === 'orange'),
diesenMonat: eintraege.filter((e) => e.stufe === 'gelb'),
weitereOffen: eintraege.filter((e) => e.stufe === 'gruen').length,
ausschlussfristen: eintraege.filter((e) => e.ausschlussfrist).slice(0, AUSSCHLUSSFRISTEN_MAX),
ausschlussfristen: eintraege.filter(warntJetzt).slice(0, AUSSCHLUSSFRISTEN_MAX),
gesamtOffen: eintraege.length,
}
}
+20
View File
@@ -163,6 +163,26 @@ export const env = {
bucket: pflicht('S3_BUCKET'),
accessKeyId: pflicht('S3_ACCESS_KEY_ID'),
secretAccessKey: pflicht('S3_SECRET_ACCESS_KEY'),
/**
* Serverseitige Verschluesselung - nur, wenn ausdruecklich gewuenscht.
*
* Frueher stand im Treiber fest ServerSideEncryption: 'AES256'. Das
* mitgelieferte MinIO laeuft ohne KMS und lehnt das mit
* "NotImplemented" ab: Es liess sich kein einziges Dokument ablegen,
* und wer der INSTALLATION.md folgte, landete zwangslaeufig dort.
*
* Gebraucht wird sie ohnehin nicht mehr. Seit v1.11.0 verschluesselt
* die Anwendung jede Datei selbst, bevor sie den Server verlaesst
* (src/lib/speicher/umschlag.ts). Serverseitige Verschluesselung
* schuetzt vor nichts, was der eigene Umschlag nicht schon abdeckt -
* sie ist hoechstens eine zweite Lage fuer Anbieter, die sie
* anbieten.
*
* Leer bedeutet aus. Wer sie will, setzt S3_SSE=AES256 oder den Wert,
* den sein Anbieter erwartet.
*/
serverseitigeVerschluesselung: process.env.S3_SSE?.trim() || null,
}
},
+30
View File
@@ -93,6 +93,36 @@ export async function signierteUrl(
return dateiRoutePfad(zugriffTokenErzeugen(schluessel, sekunden))
}
// -----------------------------------------------------------------------------
// Bereitschaftspruefung
// -----------------------------------------------------------------------------
//
// Ohne Umschlag und ohne die strenge Schluesselpruefung: Der Probeschluessel
// gehoert zu keiner betreuten Person und faellt durch das Muster.
function probepfad(schluessel: string): string {
const basis = wurzel()
const ziel = resolve(basis, ...schluessel.split('/'))
if (ziel !== basis && !ziel.startsWith(basis + sep)) {
throw new Error('Der Ablagepfad läge außerhalb des Dokumentenverzeichnisses.')
}
return ziel
}
export async function rohSchreiben(schluessel: string, inhalt: Buffer): Promise<void> {
const pfad = probepfad(schluessel)
await mkdir(dirname(pfad), { recursive: true })
await writeFile(pfad, inhalt, { mode: 0o600 })
}
export async function rohLesen(schluessel: string): Promise<Buffer> {
return readFile(probepfad(schluessel))
}
export async function rohEntfernen(schluessel: string): Promise<void> {
await rm(probepfad(schluessel), { force: true })
}
/** Nur fuer Wartungsskripte: das Ablageverzeichnis. */
export function ablageverzeichnis(): string {
return wurzel()
+129
View File
@@ -0,0 +1,129 @@
import 'server-only'
/**
* Speicherfehler in Sätze übersetzen, mit denen jemand etwas anfangen kann.
*
* Anlass ist eine Fehlersuche vom 31.08.2026. Beim Ablegen eines Schreibens
* stand in der Oberfläche:
*
* Das Schreiben konnte nicht abgelegt werden.
*
* Dahinter lagen nacheinander DREI verschiedene Ursachen — ein fehlender
* Ablagebereich, ein falsch konfigurierter Endpunkt und eine
* Verschlüsselung, die der Speicher nicht anbietet. Jedes Mal derselbe
* Satz. Die Suche dauerte entsprechend, und ohne Zugang zum Serverlog wäre
* sie aussichtslos gewesen.
*
* ## Was hier NICHT passiert
*
* Es wird nichts durchgereicht, was der Speicher sagt. Fremde
* Fehlertexte können Hostnamen, Eimernamen und Anfragekennungen
* enthalten - das gehört ins Log, nicht auf den Bildschirm eines
* Betreuers. Übersetzt werden nur die Fälle, die wir kennen; alles andere
* bleibt bei der allgemeinen Meldung des Aufrufers.
*
* Die technische Ursache steht weiterhin vollständig im Log.
*/
/** Ein erkannter Speicherfehler samt Sprachfassung. */
export type Speicherfehler = {
/** Kurzform für Protokoll und Gesundheitsauskunft. */
art: 'kein-ablagebereich' | 'verschluesselung' | 'zugang' | 'nicht-erreichbar'
/** Was in der Oberfläche stehen soll. */
meldung: string
}
/**
* Holt den Fehlercode heraus.
*
* Die Reihenfolge ist wichtig und war zuerst falsch: "name" traegt bei
* einem gewoehnlichen Node-Fehler den Wert "Error" - eine nichtssagende
* Zeichenkette, die aber nicht leer ist. Stuende sie vorn, kaeme der
* eigentliche Code aus "code" (ECONNREFUSED und Verwandte) nie zum Zug.
*
* Deshalb: erst "Code" (AWS-SDK), dann "code" (Node-Systemaufruf), und
* "name" nur als letzte Zuflucht.
*/
function code(fehler: unknown): string {
if (typeof fehler !== 'object' || fehler === null) return ''
const mitCode = fehler as { Code?: unknown; code?: unknown; name?: unknown }
for (const wert of [mitCode.Code, mitCode.code, mitCode.name]) {
if (typeof wert === 'string' && wert !== '' && wert !== 'Error') return wert
}
return ''
}
function nachricht(fehler: unknown): string {
return fehler instanceof Error ? fehler.message : ''
}
/**
* Erkennt die häufigen Speicherfehler.
*
* @returns null, wenn es keiner der bekannten Fälle ist - dann bleibt es
* bei der allgemeinen Meldung des Aufrufers.
*/
export function speicherfehlerErkennen(fehler: unknown): Speicherfehler | null {
const kennung = code(fehler)
const text = nachricht(fehler)
if (kennung === 'NoSuchBucket') {
return {
art: 'kein-ablagebereich',
meldung:
'Im Objektspeicher fehlt der Ablagebereich für die Dokumente. Er wird bei der ' +
'Installation einmalig angelegt — siehe INSTALLATION.md, Schritt 6. Bis dahin lässt ' +
'sich nichts ablegen; deine Eingaben gehen aber nicht verloren.',
}
}
// MinIO ohne KMS antwortet so, sobald S3_SSE gesetzt ist.
if (kennung === 'NotImplemented' && /encryption/i.test(text)) {
return {
art: 'verschluesselung',
meldung:
'Der Objektspeicher unterstützt die eingestellte serverseitige Verschlüsselung nicht. ' +
'Der mitgelieferte Speicher braucht sie auch nicht — die Anwendung verschlüsselt selbst. ' +
'Nimm S3_SSE aus der .env heraus.',
}
}
if (
kennung === 'AccessDenied' ||
kennung === 'SignatureDoesNotMatch' ||
kennung === 'InvalidAccessKeyId'
) {
return {
art: 'zugang',
meldung:
'Die Zugangsdaten zum Objektspeicher werden abgelehnt. Prüfe S3_ACCESS_KEY_ID und ' +
'S3_SECRET_ACCESS_KEY in der .env — und ob sie zu dem Speicher gehören, auf den ' +
'S3_ENDPOINT zeigt.',
}
}
if (
['ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN', 'TimeoutError'].includes(kennung) ||
/ECONNREFUSED|ENOTFOUND|ETIMEDOUT/.test(text)
) {
return {
art: 'nicht-erreichbar',
meldung:
'Der Objektspeicher antwortet nicht. Läuft er, und zeigt S3_ENDPOINT auf die richtige ' +
'Adresse? Deine Eingaben gehen nicht verloren.',
}
}
return null
}
/**
* Bequemer Aufruf für die Stellen, die eine Meldung brauchen.
*
* @param allgemein Was stehen soll, wenn der Fehler keiner der bekannten
* ist - z.B. "Das Schreiben konnte nicht abgelegt werden."
*/
export function speicherfehlerMeldung(fehler: unknown, allgemein: string): string {
const erkannt = speicherfehlerErkennen(fehler)
return erkannt ? erkannt.meldung : allgemein
}
+154
View File
@@ -0,0 +1,154 @@
import 'server-only'
import { randomUUID } from 'node:crypto'
import { env } from '@/lib/env'
import { speicherfehlerErkennen } from '@/lib/speicher/fehler'
/**
* Ist die Dateiablage benutzbar?
*
* Bis v1.12.0 fiel eine kaputte Speicherkonfiguration erst auf, wenn
* jemand ein Dokument ablegen wollte. Auf dem Livesystem waren das
* mehrere Wochen nach der Installation - und dann mitten in der Arbeit,
* mit einer Meldung, die nichts sagte.
*
* Diese Pruefung schreibt eine winzige Datei, liest sie zurueck und raeumt
* sie weg. Sie beantwortet damit alle drei Fragen auf einmal: erreichbar,
* Ablagebereich vorhanden, Schreibrecht da.
*
* ## Sie verhindert den Start nicht
*
* Eine Anwendung, die wegen des Objektspeichers nicht hochkommt, ist
* schlimmer als eine, die ihn meldet: Ohne Oberflaeche sieht niemand die
* Erklaerung, und der Rest der Akte - Fristen, Buchungen, Stammdaten -
* waere ohne Not mit abgeschaltet.
*/
export type Speicherzustand =
| { ok: true; treiber: string }
| { ok: false; treiber: string; art: string; meldung: string }
/** Der Ablageschluessel fuer die Probe. Liegt im eigenen Bereich. */
function probeschluessel(): string {
return `pruefung/${randomUUID()}.txt`
}
/**
* Schreibt, liest und loescht eine Probedatei.
*
* Bewusst ueber die Treiber und nicht ueber die Fassade in index.ts: Die
* prueft den Ablageschluessel gegen das Muster fuer Dokumente einer
* betreuten Person, und darum geht es hier nicht.
*/
export async function speicherPruefen(): Promise<Speicherzustand> {
const treiber = env.speicherTreiber
const schluessel = probeschluessel()
const inhalt = Buffer.from(`bereitschaftspruefung ${new Date().toISOString()}`, 'utf8')
try {
if (treiber === 's3') {
const s3 = await import('@/lib/speicher/s3')
await s3.rohSchreiben(schluessel, inhalt)
await s3.rohLesen(schluessel)
await s3.rohEntfernen(schluessel)
} else {
const datei = await import('@/lib/speicher/datei')
await datei.rohSchreiben(schluessel, inhalt)
await datei.rohLesen(schluessel)
await datei.rohEntfernen(schluessel)
}
return { ok: true, treiber }
} catch (fehler) {
const erkannt = speicherfehlerErkennen(fehler)
return {
ok: false,
treiber,
art: erkannt?.art ?? 'unbekannt',
meldung: erkannt?.meldung ?? 'Die Dateiablage ist nicht benutzbar.',
}
}
}
// -----------------------------------------------------------------------------
// Gemerkter Zustand
// -----------------------------------------------------------------------------
/**
* Warum die Pruefung an der Gesundheitsabfrage haengt und nicht an einem
* Startaufruf.
*
* Der naheliegende Ort waere instrumentation.ts - der Haken von Next.js
* fuer "einmal beim Hochfahren". Der wird aber auch fuer die
* Edge-Laufzeit gebuendelt, und ueber die Treiber kaeme node:path in ein
* Buendel, in dem es das nicht gibt: Der Build schlaegt fehl. Es gibt
* Kunstgriffe dagegen; sie sind fragil und muessten bei jedem
* Next-Wechsel nachgezogen werden.
*
* Der Healthcheck aus docker-compose ruft /api/gesundheit ohnehin ab 40
* Sekunden nach dem Start alle 30 Sekunden auf. Die erste Pruefung
* passiert damit von selbst kurz nach dem Hochfahren, und die Zeile steht
* im Log, wo man sie sucht. Ohne Behaelter geschieht dasselbe beim ersten
* Aufruf der Adresse.
*/
const NEU_PRUEFEN_NACH_MS = 5 * 60 * 1000
let gemerkt: { zustand: Speicherzustand; zeitpunkt: number } | null = null
let zuletztGemeldet: string | null = null
/**
* Der Zustand der Ablage, hoechstens alle fuenf Minuten frisch geholt.
*
* Ohne diese Schonfrist schriebe der Healthcheck alle 30 Sekunden eine
* Probedatei in den Objektspeicher - unnoetiger Verkehr fuer eine Frage,
* deren Antwort sich selten aendert.
*/
export async function speicherZustand(): Promise<Speicherzustand> {
const jetzt = Date.now()
if (gemerkt && jetzt - gemerkt.zeitpunkt < NEU_PRUEFEN_NACH_MS) {
return gemerkt.zustand
}
const zustand = await speicherPruefen()
gemerkt = { zustand, zeitpunkt: jetzt }
melden(zustand)
return zustand
}
/**
* Schreibt eine Zeile ins Protokoll - aber nur, wenn sich etwas geaendert
* hat. Sonst stuende alle fuenf Minuten dieselbe Meldung im Log und
* niemand liest sie mehr.
*/
function melden(zustand: Speicherzustand): void {
const kennung = zustand.ok ? 'ok' : `fehler:${zustand.art}`
if (kennung === zuletztGemeldet) return
zuletztGemeldet = kennung
if (zustand.ok) {
console.log(`[speicher] Ablage bereit (Treiber: ${zustand.treiber}).`)
return
}
console.error(
`[speicher] Die Dateiablage ist NICHT benutzbar (Treiber: ${zustand.treiber}, ` +
`${zustand.art}). ${zustand.meldung} ` +
`Die Anwendung laeuft trotzdem weiter - alles ausser dem Ablegen von ` +
`Dateien funktioniert.`,
)
}
/**
* Nur fuer Tests: die Schonfrist ueberspringen.
*
* Bewusst OHNE zuletztGemeldet zurueckzusetzen - das entspricht dem, was
* nach fuenf Minuten im Betrieb passiert: neu geprueft, aber nur bei einer
* Aenderung gemeldet.
*/
export function schonfristUeberspringen(): void {
gemerkt = null
}
/** Nur fuer Tests: auch das Meldegedaechtnis leeren. */
export function zustandVergessen(): void {
gemerkt = null
zuletztGemeldet = null
}
+36 -7
View File
@@ -6,6 +6,7 @@ import {
ListObjectsV2Command,
PutObjectCommand,
S3Client,
type ServerSideEncryption,
} from '@aws-sdk/client-s3'
import { env } from '@/lib/env'
import {
@@ -33,8 +34,13 @@ import { entschluesselnBinaer, verschluesselnBinaer } from '@/lib/speicher/umsch
* Kontoauszuege.
*
* Deshalb liegt jetzt derselbe Umschlag darum wie beim Dateitreiber, mit
* einem Schluessel aus der Umgebung. Die serverseitige Verschluesselung
* bleibt zusaetzlich stehen; sie kostet nichts.
* einem Schluessel aus der Umgebung.
*
* Die serverseitige Verschluesselung ist damit ueberfluessig und wird nur
* noch gesendet, wenn S3_SSE gesetzt ist. Im Kommentar stand hier einmal,
* sie "kostet nichts" - das war falsch: Das mitgelieferte MinIO laeuft
* ohne KMS und lehnte jedes PutObject mit "NotImplemented" ab. Sie kostete
* die gesamte Funktion.
*
* ## Und deshalb keine presigned URLs mehr
*
@@ -80,6 +86,7 @@ export async function schreiben(
_mimeTyp: string,
): Promise<void> {
const geprueft = schluesselErfordern(schluessel)
const sse = env.s3.serverseitigeVerschluesselung
await s3().send(
new PutObjectCommand({
Bucket: eimer(),
@@ -91,8 +98,8 @@ export async function schreiben(
ContentType: 'application/octet-stream',
// Auch beim direkten Abruf niemals im Browser darstellen.
ContentDisposition: 'attachment',
// Serverseitige Verschluesselung, wo der Anbieter sie unterstuetzt.
ServerSideEncryption: 'AES256',
// Nur mitschicken, wenn konfiguriert - siehe env.s3.
...(sse ? { ServerSideEncryption: sse as ServerSideEncryption } : {}),
}),
)
}
@@ -167,10 +174,32 @@ export async function objekteAuflisten(): Promise<string[]> {
return gefunden
}
/**
* Legt Bytes ohne Umschlag ab. Nur fuer die Bereitschaftspruefung.
*
* Bewusst ohne schluesselErfordern(): Der Probeschluessel gehoert zu
* keiner betreuten Person und faellt durch das Muster.
*/
export async function rohSchreiben(schluessel: string, inhalt: Buffer): Promise<void> {
await s3().send(
new PutObjectCommand({
Bucket: eimer(),
Key: schluessel,
Body: inhalt,
ContentType: 'application/octet-stream',
ContentDisposition: 'attachment',
}),
)
}
/** Raeumt die Probedatei wieder weg. */
export async function rohEntfernen(schluessel: string): Promise<void> {
await s3().send(new DeleteObjectCommand({ Bucket: eimer(), Key: schluessel }))
}
/** Der Inhalt, wie er im Eimer liegt - ohne den Umschlag abzunehmen. */
export async function rohLesen(schluessel: string): Promise<Buffer> {
const geprueft = schluesselErfordern(schluessel)
const antwort = await s3().send(new GetObjectCommand({ Bucket: eimer(), Key: geprueft }))
if (!antwort.Body) throw new Error(`Objekt ${geprueft} ist leer.`)
const antwort = await s3().send(new GetObjectCommand({ Bucket: eimer(), Key: schluessel }))
if (!antwort.Body) throw new Error(`Objekt ${schluessel} ist leer.`)
return Buffer.from(await antwort.Body.transformToByteArray())
}
+1 -1
View File
@@ -17,7 +17,7 @@
*/
/** Muss zur "version" in der package.json passen. */
export const VERSION = '1.11.0'
export const VERSION = '1.12.0'
/**
* Kurzer Git-Stand, beim Bauen hineingereicht:
+5 -1
View File
@@ -11,6 +11,7 @@ import { bekannteAbsender } from '@/lib/dokumente/suche'
import { erkennungsvorschlaege, type Erkennungsergebnis } from '@/lib/dokumente/erkennung'
import { ocrAnstossen } from '@/lib/dokumente/ocr-nachlauf'
import { ablegenMitTyp, DateiAbgelehnt, entfernen, signierteUrl } from '@/lib/speicher'
import { speicherfehlerMeldung } from '@/lib/speicher/fehler'
import {
DokumentMetadatenSchema,
FristUebernehmenSchema,
@@ -217,7 +218,10 @@ export async function dokumenteHochladen(
const meldung =
fehler instanceof DateiAbgelehnt
? fehler.message
: `„${anzeigename}“ konnte nicht gespeichert werden. Bitte versuche es erneut.`
: speicherfehlerMeldung(
fehler,
`„${anzeigename}“ konnte nicht gespeichert werden. Bitte versuche es erneut.`,
)
if (!(fehler instanceof DateiAbgelehnt)) {
console.error('[dokumente] Upload gescheitert:', fehler)
}
+4 -1
View File
@@ -6,6 +6,7 @@ import { db, nichtGeloescht } from '@/lib/db'
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
import { protokollieren } from '@/lib/audit'
import { ablegenMitTyp } from '@/lib/speicher'
import { speicherfehlerMeldung } from '@/lib/speicher/fehler'
import { heuteAlsTag } from '@/lib/fristen/datum'
import { tagAusText } from '@/lib/finanzen/datum'
import {
@@ -228,7 +229,9 @@ export async function schreibenAblegen(
const verweigert = zugriffsfehler(ausnahme)
if (verweigert) return verweigert
console.error('[schreiben] Ablegen fehlgeschlagen:', ausnahme)
return { fehler: 'Das Schreiben konnte nicht abgelegt werden.' }
return {
fehler: speicherfehlerMeldung(ausnahme, 'Das Schreiben konnte nicht abgelegt werden.'),
}
}
}
+110
View File
@@ -0,0 +1,110 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
/**
* Die Installationsanleitung muss ausführbar sein, nicht nur lesbar.
*
* Anlass ist eine Fehlersuche vom 31.08.2026. Drei Stellen der Anleitung
* haben dabei Zeit gekostet — keine davon war falsch formuliert, alle drei
* waren schlicht nicht durchdacht:
*
* 1. `mc alias set` wurde einmalig gesetzt. Der Alias liegt in `/root/.mc`
* **im Behälter** und ist nach jedem `docker compose up -d minio` weg.
* `mc ls lokal` liefert dann kommentarlos nichts — man hält seine
* Dokumente für verschwunden.
* 2. Der Bucket-Name stand fest im Befehl, die Anwendung liest ihn aus
* `S3_BUCKET`. Weichen sie ab, zeigen beide aneinander vorbei.
* 3. `set -a && source .env` exportiert alle Werte in die Shell — und die
* haben danach Vorrang vor der `.env`-Datei. Wer später etwas ändert
* und neu startet, sieht keine Wirkung.
*
* Diese Prüfungen lesen den Text. Sie können die Anleitung nicht
* ausführen, aber sie schlagen an, sobald jemand in eine der Fallen
* zurückfällt.
*/
const anleitung = readFileSync('docs/INSTALLATION.md', 'utf8')
/** Alle Zeilen mit einem mc-Aufruf, ohne Fließtext und Zitate. */
function mcZeilen(): string[] {
return anleitung
.split('\n')
.filter((zeile) => /\bmc (ls|mb|mirror|cp|rm|stat)\b/.test(zeile))
.filter((zeile) => !zeile.trim().startsWith('>'))
}
describe('Installationsanleitung', () => {
it('setzt bei jedem mc-Aufruf den Alias selbst', () => {
// Sonst funktioniert der Befehl nur, solange der Behaelter nicht neu
// erstellt wurde - und das sagt einem niemand.
const abschnitte = anleitung.split('```')
const ohneAlias = abschnitte
.filter((block) => /\bmc (ls|mb|mirror|cp|rm|stat)\b/.test(block))
.filter((block) => !block.includes('mc alias set'))
expect(
ohneAlias.length,
'Ein mc-Befehl ohne "mc alias set" im selben Block läuft nur, solange ' +
'der MinIO-Behälter nicht neu erstellt wurde.',
).toBe(0)
})
it('verdrahtet den Namen des Ablagebereichs nicht fest', () => {
const festVerdrahtet = mcZeilen().filter((zeile) => /lokal\/betreuungs-cockpit/.test(zeile))
expect(
festVerdrahtet,
'Der Name gehört aus $S3_BUCKET - sonst zeigen Anwendung und ' +
'Ablagebereich aneinander vorbei, sobald jemand ihn ändert.',
).toEqual([])
})
it('empfiehlt kein "source .env" mehr', () => {
// Exportierte Werte ueberstimmen danach jedes "docker compose up".
const zeilen = anleitung
.split('\n')
.filter((zeile) => !zeile.trim().startsWith('>'))
.filter((zeile) => !zeile.trim().startsWith('#'))
.filter((zeile) => /source \.env/.test(zeile))
expect(zeilen).toEqual([])
})
it('erklärt trotzdem, warum es nicht empfohlen wird', () => {
// Weglassen allein hilft nicht: Wer den Kniff von woanders kennt,
// benutzt ihn weiter.
expect(anleitung).toMatch(/Vorrang vor der/)
})
it('sagt, wo die Daten liegen', () => {
expect(anleitung).toMatch(/## Wo deine Daten liegen/)
expect(anleitung).toMatch(/docker volume inspect/)
expect(anleitung).toMatch(/minio-daten/)
expect(anleitung).toMatch(/postgres-daten/)
})
it('zeigt, wie man Akte und Speicher abgleicht', () => {
// Die wichtigste Kontrolle: Zu jedem Akteneintrag muss eine Datei
// gehoeren, sonst belegt eine Pruefsumme ein Dokument, das es nicht
// mehr gibt.
expect(anleitung).toMatch(/objektSchluessel/)
expect(anleitung).toMatch(/comm -23/)
})
it('nennt die Schlüssel, ohne die nichts mehr lesbar ist', () => {
const abschnitt = anleitung.slice(anleitung.indexOf('## Wo deine Daten liegen'))
for (const schluessel of ['ENC_KEY_V1', 'SICHERUNG_PASSWORT', 'MINIO_KMS_SECRET_KEY']) {
expect(abschnitt, `${schluessel} fehlt in der Aufzählung`).toContain(schluessel)
}
})
it('weist docker-compose.override.yml als Ort für eigene Anpassungen aus', () => {
expect(anleitung).toMatch(/docker-compose\.override\.yml/)
expect(readFileSync('.gitignore', 'utf8')).toMatch(/docker-compose\.override\.yml/)
})
it('behauptet nicht mehr, die Dokumente seien ungesichert', () => {
// Stand bis v1.12.0 so drin und stimmt seit der Sicherung nicht mehr.
expect(anleitung).not.toMatch(/Was das Backup nicht enthält:.*Dateien im Objektspeicher/s)
})
})
+77 -1
View File
@@ -92,9 +92,10 @@ describe('Fristen-Ampel', () => {
[
frist({ id: 'normal', datum: dbTag('2026-08-20') }),
frist({
// Weit genug weg fuer "gruen", nah genug fuer die Warnung.
id: 'pauschale',
titel: 'Aufwandspauschale 2026',
datum: dbTag('2026-12-31'),
datum: dbTag('2026-10-15'),
ausschlussfrist: true,
rechtsgrundlage: '§ 1878 BGB',
}),
@@ -103,6 +104,81 @@ describe('Fristen-Ampel', () => {
)
expect(daten.ausschlussfristen.map((e) => e.id)).toEqual(['pauschale'])
// Nicht in einem der Faelligkeitstoepfe - der Warnblock steht daneben.
expect(daten.weitereOffen).toBe(1)
})
describe('Ausschlussfristen: nur warnen, wenn es etwas zu tun gibt', () => {
/**
* Der Anlass: Auf dem Dashboard standen dauerhaft drei rote Kaesten mit
* Warndreieck - Aufwandspauschalen mit Verfall 2027, 2028 und 2029. Die
* Pflichtfristen werden bewusst drei Jahre im Voraus angelegt, und der
* Warnblock fragte nur, OB etwas eine Ausschlussfrist ist, nie WANN sie
* faellig wird.
*
* Eine Warnung, die monatelang dasteht und nichts von einem will,
* stumpft ab. Genau davor schuetzen diese Tests.
*/
function pauschale(id: string, datum: string) {
return frist({
id,
titel: `Aufwandspauschale ${id}`,
datum: dbTag(datum),
ausschlussfrist: true,
rechtsgrundlage: '§ 1878 BGB',
})
}
it('lässt Fristen weit in der Zukunft aus der Warnung heraus', () => {
const daten = fristenEinordnen(
[
pauschale('2027', '2027-05-06'),
pauschale('2028', '2028-05-06'),
pauschale('2029', '2029-05-06'),
],
HEUTE,
)
expect(daten.ausschlussfristen).toEqual([])
// Verschwunden sind sie deshalb nicht - sie stehen in der Liste.
expect(daten.weitereOffen).toBe(3)
expect(daten.gesamtOffen).toBe(3)
})
it('warnt, sobald eine in den Horizont rückt', () => {
// 90 Tage nach dem 19.08.2026 ist der 17.11.2026.
const daten = fristenEinordnen([pauschale('bald', '2026-11-17')], HEUTE)
expect(daten.ausschlussfristen.map((e) => e.id)).toEqual(['bald'])
})
it('warnt einen Tag davor noch nicht', () => {
const daten = fristenEinordnen([pauschale('knapp-drueber', '2026-11-18')], HEUTE)
expect(daten.ausschlussfristen).toEqual([])
})
it('warnt bei einer versäumten Frist immer, egal wie lange sie zurückliegt', () => {
// Eine verfallene Aufwandspauschale bleibt eine schlechte Nachricht.
// Sie stillschweigend aus der Warnung zu nehmen, waere das Gegenteil
// von dem, was hier gewollt ist.
const daten = fristenEinordnen([pauschale('verpasst', '2024-05-06')], HEUTE)
expect(daten.ausschlussfristen.map((e) => e.id)).toEqual(['verpasst'])
})
it('mischt Fälliges und Fernes richtig', () => {
const daten = fristenEinordnen(
[
pauschale('verpasst', '2025-05-06'),
pauschale('bald', '2026-10-01'),
pauschale('fern', '2029-05-06'),
],
HEUTE,
)
expect(daten.ausschlussfristen.map((e) => e.id)).toEqual(['verpasst', 'bald'])
})
})
it('warnt bei einer Ausschlussfrist ausdrücklich vor dem ersatzlosen Verfall', () => {
+100
View File
@@ -0,0 +1,100 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
/**
* Die Beispielkonfiguration darf keine Falle enthalten.
*
* `.env.example` wird nach `.env` kopiert und dann Zeile für Zeile
* ausgefüllt. Was schon einen Wert hat, wird dabei leicht überlesen — und
* genau das ist am 31.08.2026 passiert:
*
* S3_ENDPOINT=https://fsn1.your-objectstorage.com
*
* Die Zeile war als Beispiel gedacht. Sie stand aber aktiv in der `.env`,
* und damit sprach die Anwendung monatelang mit einem fremden Anbieter
* statt mit dem Objektspeicher nebenan. Jede Ablage scheiterte mit
* `NoSuchBucket`, und die Zugangs-ID des lokalen Speichers ging bei jedem
* Versuch nach draußen.
*
* Verschärfend: Die `docker-compose.yml` hatte längst einen brauchbaren
* Standard (`${S3_ENDPOINT:-http://minio:9000}`). Der greift aber nur bei
* leerer Variable — die Beispieldatei hebelte ihn aus. Der Mechanismus,
* der den Fehler verhindern sollte, war durch die Beispieldatei
* wirkungslos.
*
* Diese Tests halten fest: Ein Wert steht in der Beispieldatei nur, wenn
* er für die mitgelieferte Zusammenstellung auch richtig ist.
*/
const beispiel = readFileSync('.env.example', 'utf8')
/** Alle aktiven Zuweisungen - Kommentarzeilen zählen nicht. */
function zuweisungen(): [string, string][] {
return beispiel
.split('\n')
.map((zeile) => zeile.trim())
.filter((zeile) => zeile !== '' && !zeile.startsWith('#'))
.map((zeile) => {
const stelle = zeile.indexOf('=')
return [zeile.slice(0, stelle), zeile.slice(stelle + 1)] as [string, string]
})
}
/**
* Rechner, die zur mitgelieferten Zusammenstellung gehören. Alles andere
* ist ein fremder Host und hat in der Beispieldatei nichts verloren.
*/
const EIGENE_HOSTS = ['localhost', '127.0.0.1', '0.0.0.0', 'minio', 'db']
describe('Beispielkonfiguration', () => {
it('zeigt mit keinem aktiven Wert auf einen fremden Host', () => {
const fremde = zuweisungen()
.filter(([, wert]) => /https?:\/\//.test(wert))
.filter(([, wert]) => {
const treffer = wert.match(/https?:\/\/([^:/"']+)/)
return treffer ? !EIGENE_HOSTS.includes(treffer[1]!) : false
})
.map(([name, wert]) => `${name}=${wert}`)
expect(
fremde,
'Diese Zeilen zeigen nach draußen. Wer die Datei kopiert und die ' +
'Zeile übersieht, schickt seine Daten an einen fremden Rechner.',
).toEqual([])
})
it('lässt S3_ENDPOINT auskommentiert, damit der Compose-Standard greift', () => {
const aktiv = zuweisungen().some(([name]) => name === 'S3_ENDPOINT')
expect(
aktiv,
'Mit gesetztem S3_ENDPOINT greift ${S3_ENDPOINT:-http://minio:9000} ' +
'aus der docker-compose.yml nicht mehr.',
).toBe(false)
})
it('erklärt trotzdem, wann man S3_ENDPOINT braucht', () => {
// Auskommentieren allein hilft nicht - dann sucht jemand mit externem
// Speicher vergeblich nach der Einstellung.
expect(beispiel).toMatch(/#\s*S3_ENDPOINT=/)
expect(beispiel).toMatch(/EXTERNER Objektspeicher/)
})
it('enthält keine persönliche E-Mail-Adresse', () => {
// Stand einmal drin: die Adresse des Betreibers als Vorgabe fuer den
// Seed. In einer mitgelieferten Beispieldatei hat sie nichts zu suchen.
const adressen = zuweisungen()
// Verbindungszeichenfolgen tragen ein "@" vor dem Rechnernamen -
// das ist keine E-Mail-Adresse.
.filter(([, wert]) => !wert.includes('://'))
.filter(([, wert]) => /[\w.+-]+@[\w-]+\.[a-z]{2,}/i.test(wert))
.filter(([, wert]) => !/example\.(de|com|org)/.test(wert))
.map(([name, wert]) => `${name}=${wert}`)
expect(adressen).toEqual([])
})
it('findet überhaupt Zuweisungen - sonst prüft der Test nichts', () => {
expect(zuweisungen().length).toBeGreaterThan(15)
})
})
+87
View File
@@ -132,3 +132,90 @@ describe('Skripte mit pipefail', () => {
).toEqual([])
})
})
/**
* Die Sicherung muss die Dokumente enthalten - nicht nur die Datenbank.
*
* Bis v1.11.0 sicherte scripts/sicherung.sh ausschliesslich die Datenbank.
* Nach einem Plattenschaden waere die Akte vollstaendig wiederhergestellt
* worden - mit allen Eintraegen, Verweisen und Pruefsummen, aber ohne eine
* einzige Datei dahinter. Die Pruefsumme in der Akte haette dann ein
* Dokument belegt, das es nicht mehr gibt.
*
* Aufgefallen ist das nicht durch einen Test, sondern beim Nachsehen, wo
* die Daten eigentlich liegen. Die folgenden Pruefungen lesen deshalb den
* Skripttext: Sie koennen die Sicherung nicht ausfuehren - dafuer braeuchte
* es Postgres, MinIO und einen Behaelter -, aber sie schlagen an, sobald
* einer der Bausteine wieder herausfaellt.
*/
describe('Sicherung umfasst die Dokumente', () => {
const sicherung = readFileSync('scripts/sicherung.sh', 'utf8')
const wiederherstellung = readFileSync('scripts/wiederherstellung.sh', 'utf8')
it('packt das Ablageverzeichnis ein', () => {
expect(sicherung).toMatch(/tar -czf - -C "\$DOKUMENTE_QUELLE"/)
})
it('verschlüsselt das Archiv mit demselben Verfahren wie den Datenbankstand', () => {
// Zwei Aufrufe: einer fuer den Dump, einer fuer die Dokumente.
const verschluesselt = sicherung.match(/openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -salt/g)
expect(verschluesselt).toHaveLength(2)
})
it('legt zu beiden Teilen eine Prüfsumme', () => {
expect(sicherung).toMatch(/sha256sum "\$DATEI"/)
expect(sicherung).toMatch(/sha256sum "\$DOKUMENTE_DATEI"/)
})
it('bricht ab, wenn das Ablageverzeichnis nicht eingehängt ist', () => {
// Eine Sicherung, die nur die halbe Wahrheit enthaelt, aber
// vollstaendig aussieht, ist schlimmer als gar keine.
expect(sicherung).toMatch(/if \[ ! -d "\$DOKUMENTE_QUELLE" \]/)
expect(sicherung).toMatch(/rm -f "\$DATEI" "\$DATEI\.sha256"/)
})
it('prüft am Ende beide Teile, nicht nur den Datenbankstand', () => {
expect(sicherung).toMatch(/tar -tzf -/)
expect(sicherung).toMatch(/Datenbank und Dokumente/)
})
it('räumt alte Dokumentenarchive genauso auf wie alte Datenbankstände', () => {
expect(sicherung).toMatch(/dokumente_\*\.tar\.gz\.enc/)
})
it('verschont beim Monatsstand beide Teile', () => {
expect(sicherung).toMatch(/touch "\$DATEI\.monatsstand" "\$DOKUMENTE_DATEI\.monatsstand"/)
})
it('holt die Dokumente beim Zurückspielen mit zurück', () => {
expect(wiederherstellung).toMatch(/DOKUMENTE_DATEI=/)
expect(wiederherstellung).toMatch(/tar -xzf - -C "\$AUSPACKZIEL"/)
})
it('sagt beim Zurückspielen deutlich, wenn kein Dokumentenarchiv dabei ist', () => {
// Sonst haelt man eine halbe Wiederherstellung fuer eine ganze.
expect(wiederherstellung).toMatch(/kein Dokumentenarchiv/)
})
it('schreibt die Dokumente nicht selbst an ihren Platz', () => {
// Der Sicherungsdienst hat auf das Ablageverzeichnis nur Lesezugriff.
// In ein laufendes MinIO hineinzuschreiben wuerde dessen Datenbestand
// beschaedigen - der letzte Handgriff bleibt beim Menschen.
expect(wiederherstellung).toMatch(/docker compose stop minio app/)
})
})
/**
* Der Sicherungsdienst darf die Daten, die er sichert, nicht zerstoeren
* koennen.
*
* Er laeuft unbeaufsichtigt per cron. Ein Fehler im Skript oder ein
* kompromittierter Behaelter wuerde sonst Sicherung und Bestand auf einmal
* treffen - genau der Fall, gegen den eine Sicherung da ist.
*/
describe('Sicherungsdienst greift nur lesend auf die Dokumente zu', () => {
it('hängt das Ablageverzeichnis mit :ro ein', () => {
const compose = readFileSync('docker-compose.yml', 'utf8')
expect(compose).toMatch(/minio-daten:\/dokumente:ro/)
})
})
+105
View File
@@ -0,0 +1,105 @@
import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
/**
* Die Bereitschaftsprüfung der Dateiablage.
*
* Bis v1.12.0 fiel eine kaputte Speicherkonfiguration erst auf, wenn
* jemand ein Dokument ablegen wollte. Auf dem Livesystem waren das
* mehrere Wochen nach der Installation — und dann mitten in der Arbeit,
* mit einer Meldung, die nichts sagte.
*
* Die Prüfung schreibt eine winzige Datei, liest sie zurück und räumt sie
* weg. Sie beantwortet damit drei Fragen auf einmal: erreichbar,
* Ablagebereich vorhanden, Schreibrecht da.
*/
let verzeichnis = ''
beforeEach(async () => {
verzeichnis = await mkdtemp(join(tmpdir(), 'bc-pruefung-'))
process.env.SPEICHER_TREIBER = 'datei'
process.env.DATEI_SPEICHER_PFAD = verzeichnis
vi.resetModules()
})
afterEach(async () => {
await rm(verzeichnis, { recursive: true, force: true })
delete process.env.SPEICHER_TREIBER
delete process.env.DATEI_SPEICHER_PFAD
vi.restoreAllMocks()
})
describe('Bereitschaftsprüfung', () => {
it('meldet eine benutzbare Ablage', async () => {
const { speicherPruefen } = await import('@/lib/speicher/pruefung')
const zustand = await speicherPruefen()
expect(zustand.ok).toBe(true)
expect(zustand.treiber).toBe('datei')
})
it('lässt nichts liegen', async () => {
// Eine Pruefung, die bei jedem Aufruf eine Datei zuruecklaesst, waere
// nach einem Jahr Betrieb ein Verzeichnis voller Muell.
const { speicherPruefen } = await import('@/lib/speicher/pruefung')
await speicherPruefen()
await speicherPruefen()
await speicherPruefen()
const inhalt = await readdir(join(verzeichnis, 'pruefung')).catch(() => [])
expect(inhalt).toEqual([])
})
it('meldet einen Fehler, statt zu werfen', async () => {
// Der Aufrufer ist die Gesundheitsauskunft. Wirft die Pruefung, ist
// die Auskunft weg - und mit ihr die Erklaerung.
// Eine Datei als Ablageverzeichnis: Das Anlegen des Unterordners
// scheitert dann zuverlaessig und auf jedem System.
const alsDatei = join(verzeichnis, 'keine-ablage')
await writeFile(alsDatei, 'ich bin eine Datei')
process.env.DATEI_SPEICHER_PFAD = alsDatei
const { speicherPruefen } = await import('@/lib/speicher/pruefung')
const zustand = await speicherPruefen()
expect(zustand.ok).toBe(false)
if (!zustand.ok) expect(zustand.meldung.length).toBeGreaterThan(10)
})
})
describe('Gemerkter Zustand', () => {
it('prüft nicht bei jedem Aufruf neu', async () => {
// Der Healthcheck fragt alle 30 Sekunden. Ohne Schonfrist schriebe er
// ebenso oft eine Probedatei in den Objektspeicher.
const modul = await import('@/lib/speicher/pruefung')
modul.zustandVergessen()
const erste = await modul.speicherZustand()
const zweite = await modul.speicherZustand()
expect(zweite).toBe(erste)
})
it('meldet denselben Zustand nicht wieder und wieder', async () => {
const protokoll = vi.spyOn(console, 'log').mockImplementation(() => {})
const modul = await import('@/lib/speicher/pruefung')
modul.zustandVergessen()
await modul.speicherZustand()
// Wie nach fuenf Minuten: neu pruefen, aber das Meldegedaechtnis bleibt.
modul.schonfristUeberspringen()
await modul.speicherZustand()
// Zweimal geprueft, aber nur die erste Meldung - sonst stuende alle
// fuenf Minuten dieselbe Zeile im Log und niemand liest sie mehr.
const speicherzeilen = protokoll.mock.calls.filter((aufruf) =>
String(aufruf[0]).startsWith('[speicher]'),
)
expect(speicherzeilen).toHaveLength(1)
})
})
+136
View File
@@ -0,0 +1,136 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
/**
* Was der S3-Treiber tatsächlich an den Objektspeicher schickt.
*
* Anlass: Der Treiber setzte bei jedem PutObject fest
* `ServerSideEncryption: 'AES256'`. Das mitgelieferte MinIO läuft ohne KMS
* und lehnte das mit `NotImplemented` ab — es ließ sich kein einziges
* Dokument ablegen, und wer der INSTALLATION.md folgte, landete
* zwangsläufig dort.
*
* Gemerkt hat das kein Test, weil in der Entwicklung der Dateitreiber
* läuft und der S3-Pfad nie ausgeführt wurde. Diese Datei schließt genau
* diese Lücke: Sie fängt den Befehl ab, bevor er das Netz sieht, und
* schaut hinein.
*/
process.env.S3_ENDPOINT = 'http://minio:9000'
process.env.S3_BUCKET = 'betreuungs-cockpit'
process.env.S3_ACCESS_KEY_ID = 'probe-zugang'
process.env.S3_SECRET_ACCESS_KEY = 'probe-geheimnis'
/** Der letzte Befehl, den der Treiber abgeschickt hat. */
let letzterBefehl: Record<string, unknown> | null = null
vi.mock('@aws-sdk/client-s3', () => {
class PutObjectCommand {
input: Record<string, unknown>
constructor(input: Record<string, unknown>) {
this.input = input
}
}
class GetObjectCommand extends PutObjectCommand {}
class DeleteObjectCommand extends PutObjectCommand {}
class HeadObjectCommand extends PutObjectCommand {}
class ListObjectsV2Command extends PutObjectCommand {}
class S3Client {
async send(befehl: { input: Record<string, unknown> }) {
letzterBefehl = befehl.input
return {}
}
}
return {
S3Client,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
HeadObjectCommand,
ListObjectsV2Command,
}
})
const SCHLUESSEL = 'betreute/cmt4ey0jm0003o001wq4rc3m9/dokumente/ba7a8c1a-d4c5-41c7-8f5f-ebfb4805b0e5.pdf'
const INHALT = Buffer.from('%PDF-1.4 ein Bescheid', 'utf8')
/**
* Der Treiber baut seinen Klienten einmal und merkt ihn sich. Damit die
* Umgebungsvariable je Testfall neu gelesen wird, muss das Modul frisch
* geladen werden.
*/
async function treiberFrisch() {
vi.resetModules()
return import('@/lib/speicher/s3')
}
describe('S3-Treiber: serverseitige Verschlüsselung', () => {
beforeEach(() => {
letzterBefehl = null
delete process.env.S3_SSE
})
afterEach(() => {
delete process.env.S3_SSE
})
it('schickt ohne S3_SSE keinen Verschlüsselungskopf mit', async () => {
const s3 = await treiberFrisch()
await s3.schreiben(SCHLUESSEL, INHALT, 'application/pdf')
expect(letzterBefehl).not.toBeNull()
expect(letzterBefehl).not.toHaveProperty('ServerSideEncryption')
})
it('schickt ihn mit, wenn S3_SSE gesetzt ist', async () => {
process.env.S3_SSE = 'AES256'
const s3 = await treiberFrisch()
await s3.schreiben(SCHLUESSEL, INHALT, 'application/pdf')
expect(letzterBefehl?.ServerSideEncryption).toBe('AES256')
})
it('übernimmt auch einen anderen Wert, den ein Anbieter verlangt', async () => {
process.env.S3_SSE = 'aws:kms'
const s3 = await treiberFrisch()
await s3.schreiben(SCHLUESSEL, INHALT, 'application/pdf')
expect(letzterBefehl?.ServerSideEncryption).toBe('aws:kms')
})
it('behandelt eine leere Angabe wie "aus"', async () => {
// Sonst schickte eine leergelassene Zeile in der .env einen leeren
// Kopf, und der Anbieter antwortete mit einem Fehler, den niemand
// versteht.
process.env.S3_SSE = ' '
const s3 = await treiberFrisch()
await s3.schreiben(SCHLUESSEL, INHALT, 'application/pdf')
expect(letzterBefehl).not.toHaveProperty('ServerSideEncryption')
})
})
describe('S3-Treiber: was sonst noch mitgeht', () => {
beforeEach(() => {
letzterBefehl = null
delete process.env.S3_SSE
})
it('legt den Inhalt verschlüsselt ab, nicht im Klartext', async () => {
const s3 = await treiberFrisch()
await s3.schreiben(SCHLUESSEL, INHALT, 'application/pdf')
const koerper = letzterBefehl?.Body as Buffer
expect(koerper.subarray(0, 4).toString('ascii')).toBe('BCD1')
expect(koerper.includes(Buffer.from('ein Bescheid', 'utf8'))).toBe(false)
})
it('meldet den Inhaltstyp nicht als PDF - dort liegt Geheimtext', async () => {
const s3 = await treiberFrisch()
await s3.schreiben(SCHLUESSEL, INHALT, 'application/pdf')
expect(letzterBefehl?.ContentType).toBe('application/octet-stream')
expect(letzterBefehl?.ContentDisposition).toBe('attachment')
})
})
+112
View File
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest'
import { speicherfehlerErkennen, speicherfehlerMeldung } from '@/lib/speicher/fehler'
/**
* Speicherfehler müssen sagen, was zu tun ist.
*
* Anlass ist eine Fehlersuche vom 31.08.2026. In der Oberfläche stand
* dreimal hintereinander derselbe Satz — „Das Schreiben konnte nicht
* abgelegt werden." — und dahinter lagen drei völlig verschiedene
* Ursachen: ein fehlender Ablagebereich, ein Endpunkt, der auf einen
* fremden Anbieter zeigte, und eine Verschlüsselung, die der Speicher
* nicht anbietet.
*
* Ohne Zugang zum Serverlog wäre die Suche aussichtslos gewesen.
*/
/** Baut einen Fehler, wie ihn das AWS-SDK wirft. */
function sdkFehler(code: string, nachricht = ''): Error {
const fehler = new Error(nachricht || code) as Error & { Code: string }
fehler.Code = code
return fehler
}
describe('Speicherfehler erklären', () => {
it('erkennt den fehlenden Ablagebereich und nennt den Installationsschritt', () => {
const erkannt = speicherfehlerErkennen(sdkFehler('NoSuchBucket'))
expect(erkannt?.art).toBe('kein-ablagebereich')
expect(erkannt?.meldung).toMatch(/Schritt 6/)
})
it('erkennt die nicht unterstützte Verschlüsselung und nennt die Stellschraube', () => {
const erkannt = speicherfehlerErkennen(
sdkFehler('NotImplemented', 'Server side encryption specified but KMS is not configured'),
)
expect(erkannt?.art).toBe('verschluesselung')
expect(erkannt?.meldung).toMatch(/S3_SSE/)
})
it('verwechselt ein anderes NotImplemented nicht damit', () => {
// "NotImplemented" schicken S3-Nachbauten fuer allerlei; nur der Fall
// mit der Verschluesselung hat eine passende Erklaerung.
expect(speicherfehlerErkennen(sdkFehler('NotImplemented', 'Versioning not supported'))).toBeNull()
})
it.each(['AccessDenied', 'SignatureDoesNotMatch', 'InvalidAccessKeyId'])(
'erkennt %s als Zugangsproblem',
(code) => {
const erkannt = speicherfehlerErkennen(sdkFehler(code))
expect(erkannt?.art).toBe('zugang')
expect(erkannt?.meldung).toMatch(/S3_ACCESS_KEY_ID/)
},
)
it.each(['ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT'])(
'erkennt %s als nicht erreichbar',
(code) => {
const fehler = new Error('connect failed') as Error & { code: string }
fehler.code = code
expect(speicherfehlerErkennen(fehler)?.art).toBe('nicht-erreichbar')
},
)
it('verspricht bei einem Netzfehler, dass die Eingaben bleiben', () => {
const fehler = new Error('connect ECONNREFUSED 172.18.0.3:9000')
expect(speicherfehlerErkennen(fehler)?.meldung).toMatch(/gehen nicht verloren/)
})
it('gibt bei einem unbekannten Fehler null zurück', () => {
// Dann bleibt es bei der allgemeinen Meldung des Aufrufers. Fremde
// Fehlertexte durchzureichen waere die schlechtere Wahl.
expect(speicherfehlerErkennen(new Error('irgendwas ging schief'))).toBeNull()
expect(speicherfehlerErkennen(null)).toBeNull()
expect(speicherfehlerErkennen('kaputt')).toBeNull()
})
it('verrät nichts über Hostnamen, Eimer oder Anfragekennungen', () => {
// Solche Angaben stehen in den Fehlern des SDK und gehoeren ins Log,
// nicht auf den Bildschirm eines Betreuers.
const fehler = sdkFehler('NoSuchBucket', 'The specified bucket does not exist') as Error & {
BucketName: string
RequestId: string
}
fehler.BucketName = 'betreuungs-cockpit'
fehler.RequestId = '18D0FEDD87C08296'
const meldung = speicherfehlerErkennen(fehler)!.meldung
expect(meldung).not.toContain('betreuungs-cockpit')
expect(meldung).not.toContain('18D0FEDD87C08296')
expect(meldung).not.toContain('bucket')
})
})
describe('speicherfehlerMeldung', () => {
it('nimmt die erkannte Erklärung, wenn es eine gibt', () => {
const meldung = speicherfehlerMeldung(sdkFehler('NoSuchBucket'), 'Allgemeine Meldung.')
expect(meldung).not.toBe('Allgemeine Meldung.')
expect(meldung).toMatch(/Ablagebereich/)
})
it('fällt sonst auf die Meldung des Aufrufers zurück', () => {
const meldung = speicherfehlerMeldung(new Error('unbekannt'), 'Allgemeine Meldung.')
expect(meldung).toBe('Allgemeine Meldung.')
})
})
+113
View File
@@ -0,0 +1,113 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
/**
* Was die Anwendung liest, muss der Behälter auch bekommen.
*
* Die `docker-compose.yml` zählt die Umgebungsvariablen des Dienstes `app`
* einzeln auf — sie hat kein `env_file:`. Wer einen neuen Wert einführt,
* ihn in `.env.example` dokumentiert und in `env.ts` ausliest, hat damit
* noch nichts erreicht: Im Behälter kommt er nicht an.
*
* Genau das ist mit v1.11.0 passiert. `ERSTEINRICHTUNG_GEHEIMNIS` und
* `VERTRAUE_PROXY` standen in der Anleitung, in der Beispieldatei und im
* Code — nur nicht in der Compose-Datei. Die Anleitung war damit schlicht
* falsch, und der Fehler war von außen nicht zu sehen: `VERTRAUE_PROXY`
* fehlt still, die Anmeldesperre zählt dann eine Dimension weniger.
*
* Dieser Test liest beide Seiten und vergleicht sie. Er prüft kein
* Verhalten, sondern die Verpackung — wie `skripte.test.ts`.
*/
const compose = readFileSync('docker-compose.yml', 'utf8')
const envDatei = readFileSync('src/lib/env.ts', 'utf8')
const beispiel = readFileSync('.env.example', 'utf8')
/**
* Alles, was `env.ts` über seine Helfer ausliest.
*
* `process.env.X` direkt wird mitgenommen, ebenso `pflicht('X')`,
* `zahl('X', …)`, `schalter('X', …)` und `schluessel32('X')`.
*/
function gelesen(): Set<string> {
const namen = new Set<string>()
for (const treffer of envDatei.matchAll(/process\.env\.([A-Z_0-9]+)/g)) {
namen.add(treffer[1]!)
}
for (const treffer of envDatei.matchAll(
/(?:pflicht|zahl|schalter|schluessel32)\('([A-Z_0-9]+)'/g,
)) {
namen.add(treffer[1]!)
}
return namen
}
/** Die Variablen, die der Dienst `app` durchreicht. */
function durchgereicht(): Set<string> {
const abschnitt = compose.slice(compose.indexOf('\n app:'), compose.indexOf('\n sicherung:'))
const namen = new Set<string>()
for (const treffer of abschnitt.matchAll(/^ {6}([A-Z_0-9]+):/gm)) {
namen.add(treffer[1]!)
}
return namen
}
/**
* Was absichtlich nicht durchgereicht wird - jeweils mit Grund.
*
* Wer hier etwas ergänzt, nimmt es bewusst aus der Prüfung. Das ist
* erlaubt, aber es soll dabeistehen, warum.
*/
const AUSNAHMEN = new Map<string, string>([
['NODE_ENV', 'wird im Dienst gesetzt, kommt nicht aus der .env'],
['NEXT_PUBLIC_GIT_COMMIT', 'Bauargument, steckt schon im Abbild'],
['NEXT_PUBLIC_BUILD_ZEIT', 'Bauargument, steckt schon im Abbild'],
['DATEI_SPEICHER_PFAD', 'nur fuer den Dateitreiber; im Behaelter laeuft s3'],
['TESSERACT_BEFEHL', 'Standard "tesseract" stimmt im Abbild immer'],
])
describe('Umgebungsvariablen erreichen den Behälter', () => {
it('reicht jede Variable durch, die die Anwendung ausliest', () => {
const fehlend = [...gelesen()]
.filter((name) => !durchgereicht().has(name))
.filter((name) => !AUSNAHMEN.has(name))
// ENC_KEY_V2, V3 ... werden dynamisch gesucht; nur V1 ist Pflicht.
.filter((name) => !/^ENC_KEY_V\d+$/.test(name))
.sort()
expect(
fehlend,
'Diese Werte liest die Anwendung, aber die docker-compose.yml gibt sie ' +
'dem Dienst "app" nicht mit. In der .env stehen sie dann wirkungslos.',
).toEqual([])
})
it('erklärt jede Ausnahme', () => {
for (const [name, grund] of AUSNAHMEN) {
expect(grund.length, `Ausnahme ${name} ohne Begründung`).toBeGreaterThan(10)
}
})
it('dokumentiert die beiden Werte aus v1.11.0 in der Beispieldatei', () => {
// Sie waren der Anlass. Verschwinden sie aus .env.example, merkt es
// sonst niemand, bis jemand eine Neuinstallation versucht.
expect(beispiel).toMatch(/ERSTEINRICHTUNG_GEHEIMNIS=/)
expect(beispiel).toMatch(/VERTRAUE_PROXY=/)
})
it('lässt VERTRAUE_PROXY ohne Vorgabewert', () => {
// Die strengere Einstellung bleibt die Voreinstellung. Ein Vorgabewert
// "true" waere bequem und falsch: Ohne Proxy ist der weitergereichte
// Kopf frei erfunden.
expect(compose).toMatch(/VERTRAUE_PROXY: \$\{VERTRAUE_PROXY:-\}/)
})
it('findet überhaupt etwas - sonst prüft der Test nichts', () => {
// Ein stiller Ausfall der Erkennung waere schlimmer als eine Luecke:
// Der Test staende gruen da und pruefte nichts.
expect(gelesen().size).toBeGreaterThan(15)
expect(durchgereicht().size).toBeGreaterThan(15)
})
})