Compare commits
18
Commits
v1.11.0
...
5e5778b1e6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e5778b1e6 | ||
|
|
cba2e49211 | ||
|
|
c5db3baac0 | ||
|
|
274d7666ea | ||
|
|
890e00fb5c | ||
|
|
042e63812b | ||
|
|
53dde70f26 | ||
|
|
92c78a92ff | ||
|
|
c210f8bff0 | ||
|
|
5c6fac59b2 | ||
|
|
ea34294fa7 | ||
|
|
810716e4e4 | ||
|
|
88e9202c2f | ||
|
|
c0cb29bf53 | ||
|
|
dbc24d965d | ||
|
|
4d826ef55f | ||
|
|
6e6f022234 | ||
|
|
b3823094d6 |
+26
-3
@@ -103,12 +103,35 @@ VERTRAUE_PROXY=true
|
|||||||
# --- Objektspeicher (S3-kompatibel, EU!) ------------------------------------
|
# --- Objektspeicher (S3-kompatibel, EU!) ------------------------------------
|
||||||
# Wird ab Etappe 3 verwendet. Beispiel: Hetzner Object Storage, Falkenstein.
|
# Wird ab Etappe 3 verwendet. Beispiel: Hetzner Object Storage, Falkenstein.
|
||||||
# ACHTUNG: Mit dem Anbieter muss ein AV-Vertrag nach Art. 28 DSGVO bestehen.
|
# 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_REGION=eu-central
|
||||||
S3_BUCKET=betreuungs-cockpit
|
S3_BUCKET=betreuungs-cockpit
|
||||||
S3_ACCESS_KEY_ID=
|
S3_ACCESS_KEY_ID=
|
||||||
S3_SECRET_ACCESS_KEY=
|
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) ----------------------------
|
# --- E-Mail-Erinnerungen (optional, ab Etappe 2) ----------------------------
|
||||||
SMTP_HOST=
|
SMTP_HOST=
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
@@ -118,5 +141,5 @@ SMTP_FROM="Betreuungs-Cockpit <cockpit@example.de>"
|
|||||||
|
|
||||||
# --- Erstanmeldung ----------------------------------------------------------
|
# --- Erstanmeldung ----------------------------------------------------------
|
||||||
# Wird nur vom Seed benutzt, um den ersten Betreuer-Zugang anzulegen.
|
# Wird nur vom Seed benutzt, um den ersten Betreuer-Zugang anzulegen.
|
||||||
SEED_ADMIN_EMAIL=fw@wildeflorian.de
|
SEED_ADMIN_EMAIL=
|
||||||
SEED_ADMIN_PASSWORT=bitte-beim-ersten-login-aendern
|
SEED_ADMIN_PASSWORT=
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ dist/
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
*.tsbuildinfo
|
*.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
|
# Umgebung - enthaelt Schluessel, gehoert NIE ins Repo
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|||||||
+82
-1
@@ -26,6 +26,86 @@ Wiederherstellung*.
|
|||||||
|
|
||||||
_Noch nichts._
|
_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
|
## [1.11.0] – 2026-08-24
|
||||||
|
|
||||||
Diese Fassung geht aus einer Sicherheitsprüfung hervor. Sie ändert nichts
|
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.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.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
|
[1.10.1]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.10.0...v1.10.1
|
||||||
|
|||||||
@@ -93,6 +93,15 @@ services:
|
|||||||
AUTH_SECRET: ${AUTH_SECRET:?AUTH_SECRET fehlt in der .env}
|
AUTH_SECRET: ${AUTH_SECRET:?AUTH_SECRET fehlt in der .env}
|
||||||
AUTH_URL: ${AUTH_URL:?AUTH_URL fehlt in der .env}
|
AUTH_URL: ${AUTH_URL:?AUTH_URL fehlt in der .env}
|
||||||
AUTH_TRUST_HOST: 'true'
|
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}
|
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
|
# Bleibt leer, solange nicht rotiert wird. Die Anwendung ueberspringt
|
||||||
# leere Schluesselvariablen - siehe src/lib/env.ts.
|
# leere Schluesselvariablen - siehe src/lib/env.ts.
|
||||||
@@ -110,6 +119,9 @@ services:
|
|||||||
S3_BUCKET: ${S3_BUCKET:-betreuungs-cockpit}
|
S3_BUCKET: ${S3_BUCKET:-betreuungs-cockpit}
|
||||||
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID}
|
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID}
|
||||||
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY}
|
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}
|
UPLOAD_MAX_BYTES: ${UPLOAD_MAX_BYTES:-26214400}
|
||||||
OCR_AKTIV: ${OCR_AKTIV:-true}
|
OCR_AKTIV: ${OCR_AKTIV:-true}
|
||||||
SMTP_HOST: ${SMTP_HOST:-}
|
SMTP_HOST: ${SMTP_HOST:-}
|
||||||
@@ -155,9 +167,20 @@ services:
|
|||||||
PGDATABASE: ${POSTGRES_DB:-betreuungs_cockpit}
|
PGDATABASE: ${POSTGRES_DB:-betreuungs_cockpit}
|
||||||
SICHERUNG_PASSWORT: ${SICHERUNG_PASSWORT:?SICHERUNG_PASSWORT fehlt in der .env}
|
SICHERUNG_PASSWORT: ${SICHERUNG_PASSWORT:?SICHERUNG_PASSWORT fehlt in der .env}
|
||||||
SICHERUNG_AUFBEWAHRUNG_TAGE: ${SICHERUNG_AUFBEWAHRUNG_TAGE:-30}
|
SICHERUNG_AUFBEWAHRUNG_TAGE: ${SICHERUNG_AUFBEWAHRUNG_TAGE:-30}
|
||||||
|
SICHERUNG_DOKUMENTE_QUELLE: /dokumente
|
||||||
TZ: Europe/Berlin
|
TZ: Europe/Berlin
|
||||||
volumes:
|
volumes:
|
||||||
- ./backups:/backups
|
- ./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/sicherung.sh:/sicherung.sh:ro
|
||||||
- ./scripts/wiederherstellung.sh:/wiederherstellung.sh:ro
|
- ./scripts/wiederherstellung.sh:/wiederherstellung.sh:ro
|
||||||
entrypoint: ['/bin/sh', '-c']
|
entrypoint: ['/bin/sh', '-c']
|
||||||
|
|||||||
@@ -332,6 +332,136 @@ Anwendung jederzeit verlassen. Verlangt eine erneute Anmeldung.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Der Jahresabschluss, Schritt für Schritt
|
||||||
|
|
||||||
|
Einmal im Jahr will das Gericht zwei Dinge: einen **Bericht über die
|
||||||
|
Person** und eine **Rechnung über das Vermögen**. Beides bezieht sich auf
|
||||||
|
dein Betreuungsjahr, nicht auf das Kalenderjahr.
|
||||||
|
|
||||||
|
Die Reihenfolge unten ist keine Formalie. Der erste Schritt liegt sechs
|
||||||
|
Wochen vor dem Stichtag, weil manche Auskünfte so lange brauchen — wer erst
|
||||||
|
am Stichtag daran denkt, wartet und gerät in Verzug.
|
||||||
|
|
||||||
|
### Sechs Wochen vorher: anfordern, was du nicht selbst hast
|
||||||
|
|
||||||
|
Manche Werte kannst du nicht nachsehen, sondern nur erfragen:
|
||||||
|
|
||||||
|
- **Rückkaufswerte** aller Lebens-, Renten- und Sterbegeldversicherungen
|
||||||
|
- **Bescheinigungen** über Geschäftsguthaben, Bausparguthaben, Depotwerte
|
||||||
|
- **Jahresabrechnungen**, die noch ausstehen
|
||||||
|
|
||||||
|
Die Anwendung legt dir dafür rechtzeitig eine Frist an. Schreib die Briefe
|
||||||
|
gleich, wenn sie fällig wird — unter **Vorgänge** oder direkt am Vertrag.
|
||||||
|
Jedes Schreiben bekommt eine **Wiedervorlage**: Kommt in drei Wochen keine
|
||||||
|
Antwort, fasst du nach. Ohne Wiedervorlage geht eine unbeantwortete Anfrage
|
||||||
|
unter, und das merkst du am Stichtag.
|
||||||
|
|
||||||
|
### Am Stichtag: Stände eintragen
|
||||||
|
|
||||||
|
**Konten** — *Vermögen & Kasse → Konten → Konto öffnen → „Neuen Kontostand
|
||||||
|
festhalten"*. Für jedes Konto einzeln: Stichtag, Betrag, **Quelle**.
|
||||||
|
|
||||||
|
Die Quelle ist kein Beiwerk. Sie steht später neben der Zahl im Bericht,
|
||||||
|
und ein Betrag ohne Herkunft ist gegenüber dem Gericht wenig wert. Schreib
|
||||||
|
hinein, woher du die Zahl hast: „Kontoauszug 11/2026", „Online-Banking,
|
||||||
|
abgerufen am 07.11.".
|
||||||
|
|
||||||
|
Vergiss die kleinen nicht: **Verwahrgeld im Heim** und die **Barkasse**
|
||||||
|
sind Konten wie jedes andere.
|
||||||
|
|
||||||
|
**Vermögen** — *Vermögen & Kasse → Vermögen*. Immobilie, Fahrzeug,
|
||||||
|
Wertgegenstände, Forderungen, Schulden. Jeder Posten bekommt einen
|
||||||
|
**Wertstand** zum Stichtag, ebenfalls mit Quelle — und hier kannst du das
|
||||||
|
**Nachweisdokument gleich anhängen**.
|
||||||
|
|
||||||
|
> **Du musst nicht auf den Tag genau treffen.** Die Anwendung nimmt je
|
||||||
|
> Konto den letzten Stand am oder vor dem Stichtag. Weicht er ab, schreibt
|
||||||
|
> sie es in den Bericht: „Der Stand von ‚Sparbuch' stammt vom 31.10.2026,
|
||||||
|
> nicht vom Stichtag." Fehlt ein Stand ganz, steht das Konto ohne Betrag
|
||||||
|
> drin — mit Hinweis, nicht mit einer erfundenen Null.
|
||||||
|
|
||||||
|
### Dann die Berichte
|
||||||
|
|
||||||
|
1. **Vermögensübersicht oder Rechnungslegung** — *Vermögen & Kasse →
|
||||||
|
Berichte*, Betreuungsjahr wählen.
|
||||||
|
2. **Belegmappe** — *Berichte → Belegmappe*, derselbe Zeitraum. Das ist die
|
||||||
|
Anlage.
|
||||||
|
3. **Jahresbericht** — *Berichte → Jahresbericht*. Entwurf überarbeiten,
|
||||||
|
dann ausgeben.
|
||||||
|
|
||||||
|
Vorher lohnt ein Blick ins **Kassenbuch**: Buchungen ohne Beleg und ohne
|
||||||
|
Begründung fallen dem Gericht auf. Entweder den Beleg nachreichen oder in
|
||||||
|
einem Satz begründen, warum es keinen gibt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was aussieht wie ein Konto, aber keines ist
|
||||||
|
|
||||||
|
**Genossenschaftsanteile** sind der Leitfall. Im Alltag redet man von einem
|
||||||
|
Konto, das Gericht fragt einen „Kontoauszug" an — nur gibt es keinen. Am
|
||||||
|
Automaten schon gar nicht.
|
||||||
|
|
||||||
|
Der Grund: Ein Geschäftsguthaben bei einer Genossenschaft ist **kein
|
||||||
|
Zahlungskonto**, sondern eine **Forderung gegen die Genossenschaft**. Es
|
||||||
|
laufen keine Buchungen darüber, es gibt keine Umsätze, und ausgezahlt wird
|
||||||
|
erst nach Kündigung der Mitgliedschaft — mit Frist.
|
||||||
|
|
||||||
|
### Deshalb: als Vermögensposten anlegen, nicht als Konto
|
||||||
|
|
||||||
|
*Vermögen & Kasse → Vermögen → Posten anlegen*, Art **Forderung**.
|
||||||
|
|
||||||
|
Das ist kein Formalismus, sondern hat zwei handfeste Gründe:
|
||||||
|
|
||||||
|
1. **Nur am Vermögensposten kannst du den Nachweis anhängen.** Ein
|
||||||
|
Kontostand trägt eine Quellenangabe, aber kein Dokument. Die
|
||||||
|
Bescheinigung der Genossenschaft — genau das Blatt, das das Gericht
|
||||||
|
sehen will — hätte am Konto keinen Platz.
|
||||||
|
2. **Als Konto stünde es in der Kontenliste** und würde dort einen Auszug
|
||||||
|
und Buchungen erwarten, die es nie geben wird.
|
||||||
|
|
||||||
|
Trag dann einen **Wertstand** zum Stichtag ein, mit der Bescheinigung als
|
||||||
|
Nachweisdokument.
|
||||||
|
|
||||||
|
### Den „Kontoauszug" bekommst du schriftlich
|
||||||
|
|
||||||
|
Die Genossenschaft stellt dir auf Anfrage eine **Bescheinigung über das
|
||||||
|
Geschäftsguthaben** zum Stichtag aus. Viele verschicken ohnehin einmal
|
||||||
|
jährlich eine Abrechnung nach der Generalversammlung — dann genügt es,
|
||||||
|
diese abzuwarten und einzuscannen.
|
||||||
|
|
||||||
|
Fehlt sie, schreib hin. Unter **Vorlagen** liegt das *Auskunftsersuchen*
|
||||||
|
als Kopiervorlage; darin änderst du die Bitte auf „Bescheinigung über das
|
||||||
|
Geschäftsguthaben zum <Stichtag>". Und setz eine Wiedervorlage.
|
||||||
|
|
||||||
|
> **„Der Wert bleibt doch immer gleich."** Meistens ja — aber nicht
|
||||||
|
> zwangsläufig. Wird eine **Dividende dem Geschäftsguthaben
|
||||||
|
> gutgeschrieben** statt ausgezahlt, wächst es. Ebenso, wenn weitere
|
||||||
|
> Anteile gezeichnet wurden. Genau deshalb fragt man einmal im Jahr nach,
|
||||||
|
> statt den Vorjahreswert fortzuschreiben.
|
||||||
|
|
||||||
|
### Dieselbe Bauform, andere Namen
|
||||||
|
|
||||||
|
Nach demselben Muster behandelst du alles, was „Konto" heißt und keinen
|
||||||
|
Auszug kennt:
|
||||||
|
|
||||||
|
| Was | Als was anlegen | Nachweis |
|
||||||
|
|---|---|---|
|
||||||
|
| Genossenschaftsanteile | Vermögensposten, *Forderung* | Bescheinigung der Genossenschaft |
|
||||||
|
| Bausparvertrag | Vermögensposten, *Forderung* | Jahreskontoauszug der Bausparkasse |
|
||||||
|
| Sterbegeld-, Lebensversicherung | **Vertrag**, Rückkaufswert am Vertrag | Rückkaufswertmitteilung |
|
||||||
|
| Wertpapierdepot | Konto, Art *Depot* | Depotauszug zum Stichtag |
|
||||||
|
| Darlehen, das die Person schuldet | Vermögensposten, *Verbindlichkeit* | Saldenbestätigung der Bank |
|
||||||
|
|
||||||
|
Das Depot ist die Ausnahme: Dort laufen Umsätze, es gibt einen Auszug, und
|
||||||
|
es verhält sich wie ein Konto.
|
||||||
|
|
||||||
|
Bei Versicherungen nimmst du **nicht** den Vermögensbereich, sondern den
|
||||||
|
Vertrag selbst — dort gehört der Rückkaufswert hin, samt Historie über die
|
||||||
|
Jahre.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
## Anmelden mit dem Finger: Passkeys
|
## Anmelden mit dem Finger: Passkeys
|
||||||
|
|
||||||
Drei Felder auszufüllen, nur um kurz eine Frist nachzusehen, ist lästig —
|
Drei Felder auszufüllen, nur um kurz eine Frist nachzusehen, ist lästig —
|
||||||
|
|||||||
+199
-20
@@ -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 9 — Sicherung prüfen](#schritt-9--sicherung-prüfen)
|
||||||
- [Schritt 10 — Wartungslauf einrichten](#schritt-10--wartungslauf-einrichten)
|
- [Schritt 10 — Wartungslauf einrichten](#schritt-10--wartungslauf-einrichten)
|
||||||
- [Schritt 11 — Schlüssel in Sicherheit bringen](#schritt-11--schlüssel-in-sicherheit-bringen)
|
- [Schritt 11 — Schlüssel in Sicherheit bringen](#schritt-11--schlüssel-in-sicherheit-bringen)
|
||||||
|
- [Wo deine Daten liegen](#wo-deine-daten-liegen)
|
||||||
- [Aktualisieren](#aktualisieren)
|
- [Aktualisieren](#aktualisieren)
|
||||||
- [Wenn etwas nicht klappt](#wenn-etwas-nicht-klappt)
|
- [Wenn etwas nicht klappt](#wenn-etwas-nicht-klappt)
|
||||||
- [Abnahmeliste](#abnahmeliste)
|
- [Abnahmeliste](#abnahmeliste)
|
||||||
@@ -184,6 +185,13 @@ cp .env.example .env
|
|||||||
nano .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:
|
Diese Werte **musst** du setzen:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -211,6 +219,9 @@ INDEX_KEY=<aus Schritt 2>
|
|||||||
# --- Objektspeicher ---
|
# --- Objektspeicher ---
|
||||||
S3_ACCESS_KEY_ID=<aus Schritt 2>
|
S3_ACCESS_KEY_ID=<aus Schritt 2>
|
||||||
S3_SECRET_ACCESS_KEY=<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 ---
|
||||||
SICHERUNG_PASSWORT=<aus Schritt 2>
|
SICHERUNG_PASSWORT=<aus Schritt 2>
|
||||||
@@ -312,7 +323,8 @@ betreuungs-cockpit-sicherung-1 Up
|
|||||||
```bash
|
```bash
|
||||||
# Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast)
|
# Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast)
|
||||||
curl -s http://127.0.0.1:3000/api/gesundheit
|
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:
|
Kommt hier nichts, sieh in die Logs:
|
||||||
@@ -364,20 +376,42 @@ nichts kaputtmacht.
|
|||||||
Der Objektspeicher braucht einen Ablagebereich für die Dokumente:
|
Der Objektspeicher braucht einen Ablagebereich für die Dokumente:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
set -a && source .env && set +a
|
docker compose exec minio sh -c '
|
||||||
|
mc alias set lokal http://localhost:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null &&
|
||||||
docker compose exec minio mc alias set lokal \
|
mc mb "lokal/$S3_BUCKET" 2>/dev/null || true
|
||||||
http://localhost:9000 "$S3_ACCESS_KEY_ID" "$S3_SECRET_ACCESS_KEY"
|
mc ls lokal
|
||||||
|
'
|
||||||
docker compose exec minio mc mb lokal/betreuungs-cockpit
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Kontrolle
|
Es muss eine Zeile mit dem Namen deines Ablagebereichs erscheinen —
|
||||||
|
standardmäßig `betreuungs-cockpit/`.
|
||||||
|
|
||||||
```bash
|
> **Warum der Befehl so umständlich aussieht.** Er holt die Zugangsdaten
|
||||||
docker compose exec minio mc ls lokal
|
> aus den Variablen, die im MinIO-Behälter ohnehin stehen. Das hat drei
|
||||||
# [...] betreuungs-cockpit/
|
> 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
|
> **Externer Objektspeicher statt MinIO?** Wenn du einen S3-Dienst in der
|
||||||
> EU benutzt (z. B. Hetzner Object Storage, Falkenstein), streich den
|
> 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
|
## Schritt 7 — In den Reverse Proxy eintragen
|
||||||
|
|
||||||
Der Proxy steht auf einem **anderen Server**. Damit sind zwei Dinge zu
|
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 beginnt: /backups/cockpit_2026-08-22_1430.dump.enc
|
||||||
Sicherung fertig (1.2M)
|
Datenbank gesichert (1.2M)
|
||||||
Selbstpruefung bestanden.
|
Dokumente gesichert (18M)
|
||||||
|
Selbstpruefung bestanden - Datenbank und Dokumente.
|
||||||
```
|
```
|
||||||
|
|
||||||
Die **Selbstprüfung** ist der eigentliche Punkt: Das Skript entschlüsselt
|
Die **Selbstprüfung** ist der eigentliche Punkt: Das Skript entschlüsselt
|
||||||
die eben geschriebene Datei sofort wieder. Schlägt das fehl, endet der
|
beide Dateien sofort wieder und liest sie einmal durch. Schlägt das fehl,
|
||||||
Lauf mit einem Fehler statt mit einem stillen Scheinerfolg.
|
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
|
### 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
|
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
|
### Kontrolle
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -767,12 +841,18 @@ ls -lh backups/
|
|||||||
# cockpit_....dump.enc und cockpit_....dump.enc.sha256
|
# cockpit_....dump.enc und cockpit_....dump.enc.sha256
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Was das Backup nicht enthält:** den Schlüssel `ENC_KEY_V1` und die
|
> **Was das Backup enthält:** die Datenbank *und* die Dokumente. Je Lauf
|
||||||
> Dateien im Objektspeicher. Für die Dateien:
|
> 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,
|
> Und kopier die Sicherungen regelmäßig **weg vom Server** — ein Backup,
|
||||||
> das neben dem Original liegt, hilft bei einem Serverausfall nicht.
|
> 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
|
## Aktualisieren
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "betreuungs-cockpit",
|
"name": "betreuungs-cockpit",
|
||||||
"version": "1.11.0",
|
"version": "1.12.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung",
|
"description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
+81
-18
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Taegliche Sicherung der Datenbank
|
# Taegliche Sicherung: Datenbank UND Dokumente
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
#
|
#
|
||||||
# Laeuft im Behaelter "sicherung" per cron um 03:15 Uhr. Von Hand:
|
# Laeuft im Behaelter "sicherung" per cron um 03:15 Uhr. Von Hand:
|
||||||
@@ -10,17 +10,44 @@
|
|||||||
# Was passiert:
|
# Was passiert:
|
||||||
# 1. pg_dump im eigenen Format (-Fc), damit einzelne Tabellen
|
# 1. pg_dump im eigenen Format (-Fc), damit einzelne Tabellen
|
||||||
# wiederherstellbar bleiben.
|
# 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
|
# Das Backup liegt oft auf fremdem Speicher - unverschluesselt waere
|
||||||
# es ein Datenleck mit Ansage.
|
# es ein Datenleck mit Ansage.
|
||||||
# 3. SHA-256-Pruefsumme daneben.
|
# 4. SHA-256-Pruefsumme je Datei.
|
||||||
# 4. Staende, die aelter sind als SICHERUNG_AUFBEWAHRUNG_TAGE, entfernen.
|
# 5. Staende, die aelter sind als SICHERUNG_AUFBEWAHRUNG_TAGE, entfernen.
|
||||||
# Der jeweils erste Stand eines Monats bleibt stehen.
|
# Der jeweils erste Stand eines Monats bleibt stehen.
|
||||||
#
|
#
|
||||||
# WICHTIG: Dieses Backup enthaelt die verschluesselten Felder, aber NICHT
|
# ## Warum die Dokumente dazugehoeren
|
||||||
# den Schluessel ENC_KEY_V1. Ohne ihn sind Diagnosen, IBANs und Notizen
|
#
|
||||||
# auch aus einer wiederhergestellten Datenbank nicht lesbar. Den Schluessel
|
# Bis v1.11.0 sicherte dieses Skript nur die Datenbank. Nach einem
|
||||||
# getrennt sichern - Passwortmanager oder ausgedruckt im Ordner.
|
# 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
|
set -eu
|
||||||
@@ -30,6 +57,11 @@ AUFBEWAHRUNG="${SICHERUNG_AUFBEWAHRUNG_TAGE:-30}"
|
|||||||
ZEITSTEMPEL="$(date +%Y-%m-%d_%H%M)"
|
ZEITSTEMPEL="$(date +%Y-%m-%d_%H%M)"
|
||||||
TAG_IM_MONAT="$(date +%d)"
|
TAG_IM_MONAT="$(date +%d)"
|
||||||
DATEI="$ZIEL/cockpit_${ZEITSTEMPEL}.dump.enc"
|
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
|
if [ -z "${SICHERUNG_PASSWORT:-}" ]; then
|
||||||
echo "$(date -Iseconds) FEHLER: SICHERUNG_PASSWORT ist nicht gesetzt. Abbruch." >&2
|
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)"
|
GROESSE="$(du -h "$DATEI" | cut -f1)"
|
||||||
sha256sum "$DATEI" > "$DATEI.sha256"
|
sha256sum "$DATEI" > "$DATEI.sha256"
|
||||||
|
|
||||||
# Der jeweils erste Stand eines Monats bekommt eine Markierung und bleibt
|
echo "$(date -Iseconds) Datenbank gesichert ($GROESSE)"
|
||||||
# beim Aufraeumen verschont.
|
|
||||||
if [ "$TAG_IM_MONAT" = "01" ]; then
|
# --- Dokumente ---------------------------------------------------------------
|
||||||
touch "$DATEI.monatsstand"
|
# 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
|
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 --------------------------------------------------------------
|
# --- Aufraeumen --------------------------------------------------------------
|
||||||
ENTFERNT=0
|
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" ] || continue
|
||||||
[ -e "$alt.monatsstand" ] && 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 \
|
-pass env:SICHERUNG_PASSWORT -in "$DATEI" 2>/dev/null \
|
||||||
| dd bs=1 count=5 2>/dev/null || true)"
|
| dd bs=1 count=5 2>/dev/null || true)"
|
||||||
|
|
||||||
if [ "$KOPF" = "PGDMP" ]; then
|
if [ "$KOPF" != "PGDMP" ]; then
|
||||||
echo "$(date -Iseconds) Selbstpruefung bestanden."
|
echo "$(date -Iseconds) FEHLER: Der Datenbankstand laesst sich nicht entschluesseln!" >&2
|
||||||
else
|
|
||||||
echo "$(date -Iseconds) FEHLER: Die Sicherung laesst sich nicht entschluesseln!" >&2
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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."
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
# ausdruecklich nach, weil dabei der aktuelle Datenbestand ueberschrieben
|
# ausdruecklich nach, weil dabei der aktuelle Datenbestand ueberschrieben
|
||||||
# wird.
|
# wird.
|
||||||
#
|
#
|
||||||
# ZWEI DINGE, DIE MAN VORHER WISSEN MUSS:
|
# DREI DINGE, DIE MAN VORHER WISSEN MUSS:
|
||||||
#
|
#
|
||||||
# 1. Die Sicherung enthaelt die anwendungsseitig verschluesselten Felder,
|
# 1. Die Sicherung enthaelt die anwendungsseitig verschluesselten Felder,
|
||||||
# aber NICHT den Schluessel. Ohne den passenden ENC_KEY_Vn aus der .env
|
# 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
|
# das hier einmal im Jahr auf einer Testdatenbank aus, bevor du es im
|
||||||
# Ernstfall zum ersten Mal brauchst. Siehe README, Abschnitt
|
# Ernstfall zum ersten Mal brauchst. Siehe README, Abschnitt
|
||||||
# "Wiederherstellung ueben".
|
# "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
|
set -eu
|
||||||
@@ -87,7 +95,44 @@ openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
|
|||||||
| pg_restore --clean --if-exists --no-owner --no-privileges \
|
| pg_restore --clean --if-exists --no-owner --no-privileges \
|
||||||
--dbname="${PGDATABASE:-postgres}" --exit-on-error
|
--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 ""
|
||||||
echo "Naechste Schritte:"
|
echo "Naechste Schritte:"
|
||||||
echo " 1. Die SQL-Skripte erneut ausfuehren (Volltextsuche und Audit-Schutz):"
|
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 " 2. Pruefen, ob ENC_KEY_V1 in der .env zu diesem Bestand passt -"
|
||||||
echo " ohne den richtigen Schluessel bleiben die verschluesselten"
|
echo " ohne den richtigen Schluessel bleiben die verschluesselten"
|
||||||
echo " Felder unlesbar."
|
echo " Felder unlesbar."
|
||||||
echo " 3. In der Anwendung eine betreute Person oeffnen und nachsehen, ob"
|
echo " 3. Die Dokumente an ihren Platz bringen. Dazu MinIO anhalten, das"
|
||||||
echo " die Diagnosen im Klartext erscheinen. Erst dann ist die"
|
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."
|
echo " Wiederherstellung wirklich gelungen."
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { db } from '@/lib/db'
|
import { db } from '@/lib/db'
|
||||||
import { COMMIT, VERSION } from '@/lib/version'
|
import { COMMIT, VERSION } from '@/lib/version'
|
||||||
|
import { speicherZustand } from '@/lib/speicher/pruefung'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Betriebsbereitschaft fuer den Healthcheck in docker-compose.
|
* Betriebsbereitschaft fuer den Healthcheck in docker-compose.
|
||||||
*
|
*
|
||||||
* Bewusst ohne Anmeldung, aber ohne jede Auskunft ueber Inhalte: geprueft
|
* 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
|
* Die Version steht dabei: sie wird beim Aktualisieren gebraucht, um zu
|
||||||
* sehen, ob der neue Stand tatsaechlich laeuft. Das ist keine Auskunft
|
* sehen, ob der neue Stand tatsaechlich laeuft. Das ist keine Auskunft
|
||||||
@@ -19,14 +25,29 @@ export const dynamic = 'force-dynamic'
|
|||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
await db.$queryRaw`SELECT 1`
|
await db.$queryRaw`SELECT 1`
|
||||||
return NextResponse.json(
|
|
||||||
{ status: 'bereit', version: VERSION, commit: COMMIT || undefined },
|
|
||||||
{ headers: { 'Cache-Control': 'no-store' } },
|
|
||||||
)
|
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ status: 'datenbank nicht erreichbar', version: VERSION },
|
{ status: 'datenbank nicht erreichbar', version: VERSION },
|
||||||
{ status: 503, headers: { 'Cache-Control': 'no-store' } },
|
{ 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' } },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,42 @@ export const WARTEZEIT_DRINGEND_TAGE = 21
|
|||||||
/** Wie viele Ausschlussfristen zeigt die Warnung hoechstens? */
|
/** Wie viele Ausschlussfristen zeigt die Warnung hoechstens? */
|
||||||
const AUSSCHLUSSFRISTEN_MAX = 3
|
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
|
* Datumsfelder ohne Uhrzeit (@db.Date) kommen als UTC-Mitternacht aus der
|
||||||
* Datenbank. Wer sie unbesehen mit einem lokalen "heute" vergleicht,
|
* 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'),
|
dieseWoche: eintraege.filter((e) => e.stufe === 'orange'),
|
||||||
diesenMonat: eintraege.filter((e) => e.stufe === 'gelb'),
|
diesenMonat: eintraege.filter((e) => e.stufe === 'gelb'),
|
||||||
weitereOffen: eintraege.filter((e) => e.stufe === 'gruen').length,
|
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,
|
gesamtOffen: eintraege.length,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,6 +163,26 @@ export const env = {
|
|||||||
bucket: pflicht('S3_BUCKET'),
|
bucket: pflicht('S3_BUCKET'),
|
||||||
accessKeyId: pflicht('S3_ACCESS_KEY_ID'),
|
accessKeyId: pflicht('S3_ACCESS_KEY_ID'),
|
||||||
secretAccessKey: pflicht('S3_SECRET_ACCESS_KEY'),
|
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,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,36 @@ export async function signierteUrl(
|
|||||||
return dateiRoutePfad(zugriffTokenErzeugen(schluessel, sekunden))
|
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. */
|
/** Nur fuer Wartungsskripte: das Ablageverzeichnis. */
|
||||||
export function ablageverzeichnis(): string {
|
export function ablageverzeichnis(): string {
|
||||||
return wurzel()
|
return wurzel()
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -6,6 +6,7 @@ import {
|
|||||||
ListObjectsV2Command,
|
ListObjectsV2Command,
|
||||||
PutObjectCommand,
|
PutObjectCommand,
|
||||||
S3Client,
|
S3Client,
|
||||||
|
type ServerSideEncryption,
|
||||||
} from '@aws-sdk/client-s3'
|
} from '@aws-sdk/client-s3'
|
||||||
import { env } from '@/lib/env'
|
import { env } from '@/lib/env'
|
||||||
import {
|
import {
|
||||||
@@ -33,8 +34,13 @@ import { entschluesselnBinaer, verschluesselnBinaer } from '@/lib/speicher/umsch
|
|||||||
* Kontoauszuege.
|
* Kontoauszuege.
|
||||||
*
|
*
|
||||||
* Deshalb liegt jetzt derselbe Umschlag darum wie beim Dateitreiber, mit
|
* Deshalb liegt jetzt derselbe Umschlag darum wie beim Dateitreiber, mit
|
||||||
* einem Schluessel aus der Umgebung. Die serverseitige Verschluesselung
|
* einem Schluessel aus der Umgebung.
|
||||||
* bleibt zusaetzlich stehen; sie kostet nichts.
|
*
|
||||||
|
* 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
|
* ## Und deshalb keine presigned URLs mehr
|
||||||
*
|
*
|
||||||
@@ -80,6 +86,7 @@ export async function schreiben(
|
|||||||
_mimeTyp: string,
|
_mimeTyp: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const geprueft = schluesselErfordern(schluessel)
|
const geprueft = schluesselErfordern(schluessel)
|
||||||
|
const sse = env.s3.serverseitigeVerschluesselung
|
||||||
await s3().send(
|
await s3().send(
|
||||||
new PutObjectCommand({
|
new PutObjectCommand({
|
||||||
Bucket: eimer(),
|
Bucket: eimer(),
|
||||||
@@ -91,8 +98,8 @@ export async function schreiben(
|
|||||||
ContentType: 'application/octet-stream',
|
ContentType: 'application/octet-stream',
|
||||||
// Auch beim direkten Abruf niemals im Browser darstellen.
|
// Auch beim direkten Abruf niemals im Browser darstellen.
|
||||||
ContentDisposition: 'attachment',
|
ContentDisposition: 'attachment',
|
||||||
// Serverseitige Verschluesselung, wo der Anbieter sie unterstuetzt.
|
// Nur mitschicken, wenn konfiguriert - siehe env.s3.
|
||||||
ServerSideEncryption: 'AES256',
|
...(sse ? { ServerSideEncryption: sse as ServerSideEncryption } : {}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -167,10 +174,32 @@ export async function objekteAuflisten(): Promise<string[]> {
|
|||||||
return gefunden
|
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. */
|
/** Der Inhalt, wie er im Eimer liegt - ohne den Umschlag abzunehmen. */
|
||||||
export async function rohLesen(schluessel: string): Promise<Buffer> {
|
export async function rohLesen(schluessel: string): Promise<Buffer> {
|
||||||
const geprueft = schluesselErfordern(schluessel)
|
const antwort = await s3().send(new GetObjectCommand({ Bucket: eimer(), Key: schluessel }))
|
||||||
const antwort = await s3().send(new GetObjectCommand({ Bucket: eimer(), Key: geprueft }))
|
if (!antwort.Body) throw new Error(`Objekt ${schluessel} ist leer.`)
|
||||||
if (!antwort.Body) throw new Error(`Objekt ${geprueft} ist leer.`)
|
|
||||||
return Buffer.from(await antwort.Body.transformToByteArray())
|
return Buffer.from(await antwort.Body.transformToByteArray())
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/** Muss zur "version" in der package.json passen. */
|
/** 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:
|
* Kurzer Git-Stand, beim Bauen hineingereicht:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { bekannteAbsender } from '@/lib/dokumente/suche'
|
|||||||
import { erkennungsvorschlaege, type Erkennungsergebnis } from '@/lib/dokumente/erkennung'
|
import { erkennungsvorschlaege, type Erkennungsergebnis } from '@/lib/dokumente/erkennung'
|
||||||
import { ocrAnstossen } from '@/lib/dokumente/ocr-nachlauf'
|
import { ocrAnstossen } from '@/lib/dokumente/ocr-nachlauf'
|
||||||
import { ablegenMitTyp, DateiAbgelehnt, entfernen, signierteUrl } from '@/lib/speicher'
|
import { ablegenMitTyp, DateiAbgelehnt, entfernen, signierteUrl } from '@/lib/speicher'
|
||||||
|
import { speicherfehlerMeldung } from '@/lib/speicher/fehler'
|
||||||
import {
|
import {
|
||||||
DokumentMetadatenSchema,
|
DokumentMetadatenSchema,
|
||||||
FristUebernehmenSchema,
|
FristUebernehmenSchema,
|
||||||
@@ -217,7 +218,10 @@ export async function dokumenteHochladen(
|
|||||||
const meldung =
|
const meldung =
|
||||||
fehler instanceof DateiAbgelehnt
|
fehler instanceof DateiAbgelehnt
|
||||||
? fehler.message
|
? 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)) {
|
if (!(fehler instanceof DateiAbgelehnt)) {
|
||||||
console.error('[dokumente] Upload gescheitert:', fehler)
|
console.error('[dokumente] Upload gescheitert:', fehler)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { db, nichtGeloescht } from '@/lib/db'
|
|||||||
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
|
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
|
||||||
import { protokollieren } from '@/lib/audit'
|
import { protokollieren } from '@/lib/audit'
|
||||||
import { ablegenMitTyp } from '@/lib/speicher'
|
import { ablegenMitTyp } from '@/lib/speicher'
|
||||||
|
import { speicherfehlerMeldung } from '@/lib/speicher/fehler'
|
||||||
import { heuteAlsTag } from '@/lib/fristen/datum'
|
import { heuteAlsTag } from '@/lib/fristen/datum'
|
||||||
import { tagAusText } from '@/lib/finanzen/datum'
|
import { tagAusText } from '@/lib/finanzen/datum'
|
||||||
import {
|
import {
|
||||||
@@ -228,7 +229,9 @@ export async function schreibenAblegen(
|
|||||||
const verweigert = zugriffsfehler(ausnahme)
|
const verweigert = zugriffsfehler(ausnahme)
|
||||||
if (verweigert) return verweigert
|
if (verweigert) return verweigert
|
||||||
console.error('[schreiben] Ablegen fehlgeschlagen:', ausnahme)
|
console.error('[schreiben] Ablegen fehlgeschlagen:', ausnahme)
|
||||||
return { fehler: 'Das Schreiben konnte nicht abgelegt werden.' }
|
return {
|
||||||
|
fehler: speicherfehlerMeldung(ausnahme, 'Das Schreiben konnte nicht abgelegt werden.'),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Bedienungsanleitung muss die Fragen beantworten, die tatsächlich
|
||||||
|
* gestellt werden.
|
||||||
|
*
|
||||||
|
* Zwei davon kamen aus der Praxis:
|
||||||
|
*
|
||||||
|
* 1. „Wie mache ich den Jahresabschluss?" — die Anleitung beschrieb, was
|
||||||
|
* in jedem Bericht steht, aber nicht die Reihenfolge und nicht, was man
|
||||||
|
* vorher besorgen muss. Rückkaufswerte brauchen Wochen.
|
||||||
|
* 2. „Das Gericht will einen Kontoauszug für die Genossenschaftsanteile —
|
||||||
|
* den kann ich nicht am Automaten holen." Richtig: Es ist kein
|
||||||
|
* Zahlungskonto, sondern eine Forderung, und der Nachweis ist eine
|
||||||
|
* Bescheinigung, die man anfordert.
|
||||||
|
*
|
||||||
|
* Der zweite Fall ist der wichtigere, weil er zu einem stillen Fehler
|
||||||
|
* führt: Wer die Anteile als Konto anlegt, kann die Bescheinigung nicht am
|
||||||
|
* Wert hinterlegen — genau das Dokument, das das Gericht sehen will.
|
||||||
|
*/
|
||||||
|
describe('Bedienungsanleitung', () => {
|
||||||
|
const bedienung = readFileSync('docs/ANLEITUNG.md', 'utf8')
|
||||||
|
|
||||||
|
it('erklärt den Jahresabschluss mit Reihenfolge', () => {
|
||||||
|
expect(bedienung).toMatch(/## Der Jahresabschluss, Schritt für Schritt/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sagt, was vor dem Stichtag zu besorgen ist', () => {
|
||||||
|
// Wer erst am Stichtag daran denkt, wartet Wochen auf die Auskunft.
|
||||||
|
const abschnitt = bedienung.slice(bedienung.indexOf('## Der Jahresabschluss'))
|
||||||
|
expect(abschnitt).toMatch(/Sechs Wochen vorher/)
|
||||||
|
expect(abschnitt).toMatch(/Rückkaufswerte/)
|
||||||
|
expect(abschnitt).toMatch(/Wiedervorlage/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('besteht auf der Quellenangabe beim Kontostand', () => {
|
||||||
|
// Ein Betrag ohne Herkunft ist gegenueber dem Gericht wenig wert.
|
||||||
|
const abschnitt = bedienung.slice(bedienung.indexOf('## Der Jahresabschluss'))
|
||||||
|
expect(abschnitt).toMatch(/Quelle/)
|
||||||
|
expect(abschnitt).toMatch(/Kontoauszug 11\/2026|Online-Banking/)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Was aussieht wie ein Konto', () => {
|
||||||
|
const abschnitt = bedienung.slice(bedienung.indexOf('## Was aussieht wie ein Konto'))
|
||||||
|
|
||||||
|
it('gibt es überhaupt', () => {
|
||||||
|
expect(abschnitt.length).toBeGreaterThan(500)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('führt Genossenschaftsanteile als Forderung, nicht als Konto', () => {
|
||||||
|
expect(abschnitt).toMatch(/Genossenschaftsanteile/)
|
||||||
|
expect(abschnitt).toMatch(/Art \*\*Forderung\*\*/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nennt den Grund, statt ihn vorauszusetzen', () => {
|
||||||
|
// Ohne Begruendung legt der naechste Leser es wieder als Konto an.
|
||||||
|
expect(abschnitt).toMatch(/Nachweis anhängen|Nachweis anhaengen/)
|
||||||
|
expect(abschnitt).toMatch(/kein Dokument/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('warnt davor, den Vorjahreswert einfach fortzuschreiben', () => {
|
||||||
|
// "Der Wert bleibt doch gleich" stimmt meistens - aber eine
|
||||||
|
// gutgeschriebene Dividende erhoeht das Geschaeftsguthaben.
|
||||||
|
expect(abschnitt).toMatch(/Dividende/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sagt, wie man den Nachweis bekommt', () => {
|
||||||
|
expect(abschnitt).toMatch(/Bescheinigung über das\s+Geschäftsguthaben/)
|
||||||
|
expect(abschnitt).toMatch(/Auskunftsersuchen/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('behandelt die verwandten Fälle mit', () => {
|
||||||
|
for (const fall of ['Bausparvertrag', 'Depot', 'Verbindlichkeit']) {
|
||||||
|
expect(abschnitt, `${fall} fehlt in der Übersicht`).toContain(fall)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('schickt Versicherungen zum Vertrag, nicht ins Vermögen', () => {
|
||||||
|
// Der Rueckkaufswert gehoert an den Vertrag, samt Historie.
|
||||||
|
expect(abschnitt).toMatch(/nicht\*\* den Vermögensbereich|Vertrag selbst/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
+77
-1
@@ -92,9 +92,10 @@ describe('Fristen-Ampel', () => {
|
|||||||
[
|
[
|
||||||
frist({ id: 'normal', datum: dbTag('2026-08-20') }),
|
frist({ id: 'normal', datum: dbTag('2026-08-20') }),
|
||||||
frist({
|
frist({
|
||||||
|
// Weit genug weg fuer "gruen", nah genug fuer die Warnung.
|
||||||
id: 'pauschale',
|
id: 'pauschale',
|
||||||
titel: 'Aufwandspauschale 2026',
|
titel: 'Aufwandspauschale 2026',
|
||||||
datum: dbTag('2026-12-31'),
|
datum: dbTag('2026-10-15'),
|
||||||
ausschlussfrist: true,
|
ausschlussfrist: true,
|
||||||
rechtsgrundlage: '§ 1878 BGB',
|
rechtsgrundlage: '§ 1878 BGB',
|
||||||
}),
|
}),
|
||||||
@@ -103,6 +104,81 @@ describe('Fristen-Ampel', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(daten.ausschlussfristen.map((e) => e.id)).toEqual(['pauschale'])
|
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', () => {
|
it('warnt bei einer Ausschlussfrist ausdrücklich vor dem ersatzlosen Verfall', () => {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -132,3 +132,90 @@ describe('Skripte mit pipefail', () => {
|
|||||||
).toEqual([])
|
).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/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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.')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user