diff --git a/.env.example b/.env.example index 6e8379e..3d36044 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,17 @@ APP_SECRET= # # DATABASE_URL gilt fuer alles, was ausserhalb der Container laeuft: `pnpm dev`, # `pnpm prisma …`, `pnpm db:seed` von der Werkbank aus. Standard ist die +# ---- Anhaenge (#032) ------------------------------------------------------- +# Wo die Dateien der Anhaenge liegen. Im Compose-Stand setzt docker-compose.yml +# das selbst auf /daten/anhaenge im Volume `anhaenge`; hier gilt es fuer die +# Entwicklung und die native Installation. +# +# **Gehoert in jede Sicherung.** In der Datenbank stehen nur die Metadaten. +ANHAENGE_PFAD=./daten/anhaenge +# Hoechstgroesse je Datei in MB und Hoechstzahl Anhaenge je Lauf. +ANHANG_MAX_MB=10 +ANHAENGE_JE_LAUF=20 + # Entwicklungsdatenbank aus docker-compose.dev.yml (Port 5434, umstellbar ueber DEV_DB_PORT). POSTGRES_USER=laufzettel POSTGRES_PASSWORD= diff --git a/.gitignore b/.gitignore index 502034c..51088fb 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,10 @@ blob-report/ # Release liegt es bei, so steht es im Auftrag. /docs/anleitung/*.pdf +# Hochgeladene Anhaenge (#032). Nutzdaten, nie Quellcode – in der Entwicklung +# landen sie unter `daten/anhaenge`, im Betrieb ausserhalb des Arbeitsbaums. +/daten/ + # Werkzeuge *.tsbuildinfo .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index d076b4c..198d70e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Geändert +- **`docs/betrieb/sicherung.md` ist neu geschrieben.** Dort stand „Kein Upload-Verzeichnis, keine + erzeugten Dateien“ — mit den Anhängen ist das falsch, und eine Sicherung nach der alten Anleitung + hätte ab #032 still Daten verloren: in der Datenbank stehen nur die Metadaten. Jetzt sind es drei + Dinge statt zwei, mit `tar` für beide Betriebswege, dem `chown`-Fallstrick nach einer + Rücksicherung als `root`, einem Sicherungsskript, das Datenbank und Anhänge im **selben** Lauf + mitnimmt, und einer Gegenprobe über die Zahlen. Sicherung und Rückspielung sind einmal wirklich + gefahren (#032) - Der Seed macht Petra Klein zur Vorgesetzten von Maria Huber — die Zuordnung aus dem Auftrag, die sich vorher nirgends speichern ließ (#020) - Der Healthcheck `/api/gesundheit` nennt im Fehlerfall keinen Grund mehr. Der Pfad ist ohne @@ -26,6 +33,14 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Hinzugefügt +- **Fotos und Dateien an einem Schritt.** „Seriennummer fotografiert“, „Typenschild“, „Zustand bei + Übergabe“ — manches lässt sich nicht abhaken, es muss gezeigt werden. Ein Schritt nimmt jetzt + Bilder (JPEG, PNG, WebP, HEIC) und PDF auf, am Handy direkt aus der Kamera. Die Dateien liegen im + Dateisystem unter `ANHAENGE_PFAD`, nach Kunde und Lauf gegliedert; in der Datenbank stehen nur + Name, Typ, Größe und Prüfsumme. Jeder Abruf prüft Recht und Mandantengrenze — auch der des Bildes + selbst. Grenzen über `ANHANG_MAX_MB` (10) und `ANHAENGE_JE_LAUF` (20), und eine abgewiesene Datei + nennt den Grund. Kein SVG: das ist ein Dokument, das Skripte ausführt. Der Archiv-Schreibschutz + gilt auch hier, und die Druckansicht zeigt Bilder als Miniatur, ein PDF als Zeile (#032) - **Abschnitte nur für bestimmte Abteilungen.** Beim Onboarding in der Buchhaltung gehören Schritte dazu, die sonst niemand braucht. Bisher hieß die Wahl: eine Vorlage je Abteilung pflegen, oder Schritte, die für die meisten „nicht zutreffend“ sind. Jeder Abschnitt kann jetzt sagen, für welche diff --git a/deploy/install.sh b/deploy/install.sh index 9abc3c5..9559c62 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -23,6 +23,11 @@ readonly STANDALONE="$ZIEL/.next/standalone" # Zwischenspeicher von pnpm und corepack. So bleibt der Checkout unter /opt # sauber und `git pull` findet nichts Fremdes vor. readonly HEIM="/var/cache/laufzettel" +# Die Dateien der Anhaenge (#032). Unter /var/lib und nicht unter /opt: /opt ist +# der Quellcode und wird bei jedem Aktualisieren angefasst, /var/lib ist +# veraenderlicher Zustand, der bleibt. **Gehoert in jede Sicherung** -- in der +# Datenbank stehen nur die Metadaten. +readonly ANHAENGE="/var/lib/laufzettel/anhaenge" readonly ETC_VERZEICHNIS="/etc/laufzettel" readonly UMGEBUNGSDATEI="$ETC_VERZEICHNIS/laufzettel.env" readonly UNIT_DATEI="/etc/systemd/system/laufzettel.service" @@ -330,6 +335,9 @@ nutzerAnlegen() { install -d -o "$DIENSTNUTZER" -g "$DIENSTNUTZER" -m 0755 "$ZIEL" install -d -o "$DIENSTNUTZER" -g "$DIENSTNUTZER" -m 0750 "$HEIM" install -d -o root -g root -m 0750 "$ETC_VERZEICHNIS" + # 0750 und nicht 0755: ein Anhang kann ein Foto eines Typenschilds oder eine + # Rechnung sein. Nur der Dienstnutzer liest hier. + install -d -o "$DIENSTNUTZER" -g "$DIENSTNUTZER" -m 0750 "$ANHAENGE" } # ---------- Schritt 4: Geheimnisse und Umgebungsdatei ----------------------- @@ -365,6 +373,11 @@ DATABASE_URL="postgresql://$DB_ROLLE:$dbPasswort@localhost/$DATENBANK?host=$SOCK HOSTNAME="$bindAdresse" PORT="$port" +# Wo die Dateien der Anhaenge liegen (#032). In der Datenbank stehen nur ihre +# Metadaten -- dieses Verzeichnis gehoert deshalb in jede Sicherung, sonst +# zeigen die Anhaenge nach einer Ruecksicherung ins Leere. +ANHAENGE_PFAD="$ANHAENGE" + # Lokaler Login mit E-Mail und Passwort – der Notfallzugang, mit dem diese # Installation bedienbar ist. Sobald OIDC eingerichtet ist: auf false setzen. AUTH_LOCAL_ENABLED="true" diff --git a/docker-compose.yml b/docker-compose.yml index b39a449..fd56127 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -99,9 +99,19 @@ services: # Der Verzeichnis-Abgleich laeuft im selben Prozess (#016). Hier steht er # an, weil dieser Dienst der einzige ist, der dauerhaft laeuft. ZEITPLAN_AKTIV: ${ZEITPLAN_AKTIV:-true} + # Anhaenge und Fotos liegen im Dateisystem, nicht in der Datenbank (#032). + # Der Pfad zeigt in das Volume `anhaenge` unten. + ANHAENGE_PFAD: /daten/anhaenge + ANHANG_MAX_MB: ${ANHANG_MAX_MB:-10} + ANHAENGE_JE_LAUF: ${ANHAENGE_JE_LAUF:-20} # Standardmaessig nur auf localhost; davor gehoert ein Reverse Proxy. ports: - "${BIND_ADDR:-127.0.0.1}:${APP_PORT:-3000}:3000" + volumes: + # **Gehoert in jede Sicherung.** Eine Sicherung, die nur `pg_dump` macht, + # verliert ab hier Daten: in der Datenbank stehen nur die Metadaten der + # Anhaenge, die Dateien liegen hier. Siehe docs/betrieb/sicherung.md. + - anhaenge:/daten/anhaenge depends_on: postgres: condition: service_healthy @@ -130,3 +140,6 @@ networks: volumes: datenbank: + # Die Dateien der Anhaenge (#032). Zusammen mit der Datenbank zu sichern – + # getrennt gesichert ergeben beide Haelften kein vollstaendiges Bild. + anhaenge: diff --git a/docs/anleitung/02-laeufe-abarbeiten.md b/docs/anleitung/02-laeufe-abarbeiten.md index 33d98ac..7680d00 100644 --- a/docs/anleitung/02-laeufe-abarbeiten.md +++ b/docs/anleitung/02-laeufe-abarbeiten.md @@ -195,6 +195,33 @@ Zwei Wege: die Person aus der Liste wählen, oder — wenn das Konto gerade erst entstanden und noch nicht abgeglichen ist — ihren **UPN** eintragen. Danach **Verknüpfen**. Ohne das findet die Person in ihrer Historie später nichts wieder. +## Fotos und Dateien an einem Schritt + +Manches lässt sich nicht abhaken, es muss gezeigt werden: das Typenschild, der Zustand bei der +Übergabe, die Rechnung. An jedem Schritt stehen dafür zwei Knöpfe: + +- **Foto** öffnet am Handy direkt die Kamera. Am Rechner zeigt der Browser den Dateiwähler — dort + gibt es keine Kamera-App. +- **Datei** öffnet den Dateiwähler. + +Abgeschickt wird **sofort nach der Wahl**, ohne zweiten Knopf. Ein Foto, das du gerade gemacht hast, +willst du nicht noch bestätigen. + +Erlaubt sind **Bilder** (JPEG, PNG, WebP, HEIC) und **PDF**, höchstens 10 MB je Datei und 20 Dateien +je Lauf. Beide Grenzen lassen sich beim Betrieb ändern (`ANHANG_MAX_MB`, `ANHAENGE_JE_LAUF`). Eine zu +große Datei wird schon im Browser abgewiesen — am Handy im Mobilfunk ist das der Unterschied zwischen +einer Meldung und einer Minute Warten. + +Bilder erscheinen als Miniatur, ein PDF als Zeile mit Namen. Ein Klick öffnet die Datei in einem +neuen Tab; ein PDF wird dabei heruntergeladen statt im Browser angezeigt. + +**An einem archivierten Lauf kommt kein Anhang mehr dazu und keiner weg** — dieselbe Regel wie für +Schritte und Kommentare. Ansehen geht weiter: ein Foto vom Typenschild ist Teil des Belegs. + +> **Für den Betrieb wichtig:** Die Dateien liegen im Dateisystem, in der Datenbank stehen nur ihre +> Namen und Größen. Eine Sicherung, die nur die Datenbank mitnimmt, verliert sie — siehe +> `docs/betrieb/sicherung.md`. + ## Abschließen **Lauf abschließen** geht nur, wenn kein Pflichtschritt mehr offen ist. Erledigt und „nicht diff --git a/docs/anleitung/08-fehlerbehebung.md b/docs/anleitung/08-fehlerbehebung.md index 6ee2620..d9d3f56 100644 --- a/docs/anleitung/08-fehlerbehebung.md +++ b/docs/anleitung/08-fehlerbehebung.md @@ -77,6 +77,36 @@ Zurückholen wird protokolliert. **Vorbeugen:** archiviere erst, wenn wirklich alles drinsteht. Es gibt keinen Grund zur Eile — ein abgeschlossener Lauf steht auch schon im Archiv und ist durchsuchbar. +## „Die Datei zu … fehlt im Dateisystem“ + +**Ursache:** In der Datenbank steht die Zeile eines Anhangs, die Datei selbst ist nicht da. Fast +immer heißt das: es wurde eine Sicherung eingespielt, die nur die Datenbank enthielt. Die Dateien der +Anhänge liegen im Dateisystem, nicht in der Datenbank. + +**Was tun:** Das Verzeichnis aus derselben Sicherungsrunde zurückspielen — +`docs/betrieb/sicherung.md` beschreibt beide Wege. Gibt es kein Archiv der Dateien, sind sie weg; die +Zeilen lassen sich dann nur entfernen, indem jemand den Anhang in der Oberfläche löscht. + +**Zweitursache, seltener:** nach einer Rücksicherung gehören die Dateien `root` statt dem +Dienstnutzer. Der Dienst kommt dann nicht an sie heran, obwohl sie daliegen: + +```bash +sudo ls -l /var/lib/laufzettel/anhaenge +sudo chown -R laufzettel:laufzettel /var/lib/laufzettel/anhaenge +``` + +**Vorbeugen:** Datenbank und Anhänge im **selben** Skript sichern, direkt hintereinander. Getrennt +gesichert passen die beiden Hälften irgendwann nicht mehr zusammen. + +## „Dateien vom Typ … lassen sich nicht anhängen“ + +**Ursache:** Erlaubt sind Bilder (JPEG, PNG, WebP, HEIC) und PDF. Alles andere wird abgewiesen — +auch SVG, und das ist Absicht: ein SVG ist ein Dokument, das Skripte ausführen kann, und ein Anhang, +den jemand öffnet, soll kein Angriffsweg sein. + +**Was tun:** Ein Office-Dokument oder eine Textdatei als PDF ausgeben. Ein Screenshot wird zu PNG, +sobald du ihn speicherst statt ihn aus der Zwischenablage zu ziehen. + ## „Kein Laufzettel-Tag“ nach dem Scannen **Ursache:** einer von drei Fällen, und Laufzettel unterscheidet sie absichtlich nicht — die diff --git a/docs/betrieb/sicherung.md b/docs/betrieb/sicherung.md index 899682c..0a3e38d 100644 --- a/docs/betrieb/sicherung.md +++ b/docs/betrieb/sicherung.md @@ -11,12 +11,19 @@ ebenfalls einfach, aber nur, wenn man es einmal geübt hat. ## Was gesichert werden muss -Zwei Dinge, und nur diese zwei: +Drei Dinge, und nur diese drei: | | Was | Wo | |---|---|---| -| **1** | **Die Datenbank.** Alles Veränderliche steckt darin: Läufe, Vorlagen, Schrittergebnisse, Kommentare, Geräte, Personen, Tag-Codes, Protokoll. | `pg_dump`, siehe unten | +| **1** | **Die Datenbank.** Läufe, Vorlagen, Schrittergebnisse, Kommentare, Geräte, Personen, Tag-Codes, Protokoll. | `pg_dump`, siehe unten | | **2** | **Die Geheimnisse.** `APP_SECRET` und das Datenbank-Passwort. | Compose: die `.env` · Nativ: `/etc/laufzettel/laufzettel.env` | +| **3** | **Die Anhänge.** Die Dateien und Fotos an den Schritten. | Volume `laufzettel_anhaenge` · Nativ: `ANHAENGE_PFAD` | + +> **Die Anhänge sind seit #032 dabei, und sie sind die Falle.** In der Datenbank stehen nur ihre +> **Metadaten** — Name, Typ, Größe, Prüfsumme. Die Dateien selbst liegen im Dateisystem. Eine +> Sicherung, die nur `pg_dump` macht, sieht vollständig aus und ist es nicht: nach dem Zurückspielen +> stehen die Anhänge in der Oberfläche, und jeder Klick darauf meldet, dass die Datei fehlt. +> Datenbank und Verzeichnis gehören **zum selben Zeitpunkt** gesichert. **Warum die Geheimnisse dazugehören:** `APP_SECRET` verschlüsselt nicht nur die Sitzungs-Cookies, sondern auch die gespeicherten LDAP-Bind-Passwörter. Spielst du eine Datenbank in eine Installation @@ -29,9 +36,8 @@ Sicher die beiden Dinge getrennt und leg das Geheimnis dahin, wo auch die restli Hauses liegen — in den Passwortmanager. Eine Datei mit `APP_SECRET` neben dem Datenbank-Abzug auf derselben Netzwerkfreigabe hebt den Zweck der Verschlüsselung auf. -**Was nicht gesichert werden muss:** Die Anwendung hält keinen veränderlichen Zustand auf der Platte. -Kein Upload-Verzeichnis, keine erzeugten Dateien. Der Quellcode kommt aus Git, `node_modules` und -`.next` entstehen beim Bauen neu, das PDF der Anleitung liegt am Release. +**Was nicht gesichert werden muss:** Der Quellcode kommt aus Git, `node_modules` und `.next` +entstehen beim Bauen neu, das PDF der Anleitung liegt am Release. **Was der Abzug bewusst nicht enthält:** ein `{{generiertes_passwort}}`. Es wird bei jeder Anzeige neu erzeugt und nie gespeichert — nicht im Schrittergebnis, nicht im Kommentar, nicht im Protokoll. Wer @@ -73,6 +79,28 @@ Das Volume heißt `laufzettel_datenbank`. Es mit `docker run --volumes-from` weg **keine** gleichwertige Sicherung: ein Dateiabzug eines laufenden PostgreSQL ist im besten Fall brauchbar und im schlechteren still beschädigt. Nimm `pg_dump`. +#### Und die Anhänge + +Hier gilt das Umgekehrte: ein Dateiabzug ist genau richtig, denn es sind gewöhnliche Dateien. + +```bash +docker run --rm \ + -v laufzettel_anhaenge:/daten:ro \ + -v "$PWD":/ziel \ + debian:13 tar czf /ziel/anhaenge-$(date +%F).tar.gz -C /daten . +``` + +Kurz nachsehen, dass etwas drin ist: + +```bash +tar tzf anhaenge-$(date +%F).tar.gz | head -5 +tar tzf anhaenge-$(date +%F).tar.gz | wc -l +``` + +Ist die Datei leer und es gibt Anhänge in der Oberfläche, stimmt der Volume-Name nicht — `docker +volume ls | grep anhaenge` nennt den richtigen. Er trägt den Projektnamen als Präfix, und der kommt +aus dem Verzeichnisnamen. + ### Zurückspielen Die Datenbank muss dafür leer sein — ein Abzug in eine gefüllte Datenbank gespielt bringt lauter @@ -91,10 +119,18 @@ docker compose exec -T postgres createdb -U laufzettel -O laufzettel laufzettel # 3. Abzug einspielen gunzip -c sicherung-2026-09-24.sql.gz | docker compose exec -T postgres psql -U laufzettel laufzettel -# 4. Migrationen nachziehen – nötig, wenn der Abzug von einer älteren Fassung stammt +# 4. Die Anhänge zurück ins Volume – **vorher leeren**, sonst bleiben Dateien +# liegen, auf die keine Zeile mehr zeigt +docker run --rm -v laufzettel_anhaenge:/daten debian:13 sh -c 'rm -rf /daten/* /daten/.[!.]*' || true +docker run --rm \ + -v laufzettel_anhaenge:/daten \ + -v "$PWD":/quelle:ro \ + debian:13 tar xzf /quelle/anhaenge-2026-09-24.tar.gz -C /daten + +# 5. Migrationen nachziehen – nötig, wenn der Abzug von einer älteren Fassung stammt docker compose run --rm migrate pnpm exec prisma migrate deploy -# 5. Wieder anfahren +# 6. Wieder anfahren docker compose start web curl -s http://127.0.0.1:3000/api/gesundheit ``` @@ -118,6 +154,14 @@ auf —, aber alles, was er anlegen würde, steht schon im Abzug. sudo -u postgres pg_dump -Fc laufzettel > /var/backups/laufzettel-$(date +%F).dump ``` +Und die Anhänge dazu — gewöhnliche Dateien, also `tar`: + +```bash +# Der Pfad steht in ANHAENGE_PFAD; ohne Angabe liegt er unter /opt/laufzettel/daten/anhaenge. +ANHAENGE=$(sudo sh -c '. /etc/laufzettel/laufzettel.env; echo "${ANHAENGE_PFAD:-/opt/laufzettel/daten/anhaenge}"') +sudo tar czf /var/backups/laufzettel-anhaenge-$(date +%F).tar.gz -C "$ANHAENGE" . +``` + Und das Geheimnis, einmalig und in den Passwortmanager: ```bash @@ -141,16 +185,29 @@ sudo -u postgres createdb -O laufzettel laufzettel # 3. Abzug einspielen sudo -u postgres pg_restore -d laufzettel --no-owner /var/backups/laufzettel-2026-09-24.dump -# 4. Migrationen nachziehen, wenn der Abzug älter ist als der Code +# 4. Die Anhänge zurück – **vorher leeren**, sonst bleiben Dateien liegen, auf +# die keine Zeile mehr zeigt +ANHAENGE=$(sudo sh -c '. /etc/laufzettel/laufzettel.env; echo "${ANHAENGE_PFAD:-/opt/laufzettel/daten/anhaenge}"') +sudo rm -rf "$ANHAENGE" +sudo install -d -o laufzettel -g laufzettel -m 0750 "$ANHAENGE" +sudo tar xzf /var/backups/laufzettel-anhaenge-2026-09-24.tar.gz -C "$ANHAENGE" +sudo chown -R laufzettel:laufzettel "$ANHAENGE" + +# 5. Migrationen nachziehen, wenn der Abzug älter ist als der Code sudo bash /opt/laufzettel/deploy/install.sh -# 5. Wieder anfahren und nachsehen +# 6. Wieder anfahren und nachsehen sudo systemctl start laufzettel curl -s http://127.0.0.1:3000/api/gesundheit ``` -Schritt 4 ist der Installationsaufruf, weil er die Migrationen mitbringt und den Dienst gleich -neu startet — Schritt 5 ist dann nur noch die Gegenprobe. Willst du nur die Migrationen: +Schritt 5 ist der Installationsaufruf, weil er die Migrationen mitbringt und den Dienst gleich +neu startet — Schritt 6 ist dann nur noch die Gegenprobe. + +**Der `chown` in Schritt 4 ist nicht Beiwerk.** `tar` legt die Dateien mit den Besitzern aus dem +Archiv an; läuft die Rücksicherung als `root`, gehören sie danach `root`, und der Dienstnutzer +`laufzettel` kommt nicht mehr an sie heran. Jeder Anhang meldet dann „Datei fehlt“, obwohl sie +daliegt. Willst du nur die Migrationen: ```bash cd /opt/laufzettel @@ -170,11 +227,30 @@ After=postgresql.service [Service] Type=oneshot -User=postgres -# %i-freies Einzeiler-Skript: pg_dump in eine Datei mit Datum, dann alles -# aelter als 30 Tage weg. -ExecStart=/bin/bash -c 'pg_dump -Fc laufzettel > /var/backups/laufzettel-$(date +%%F).dump' -ExecStartPost=/usr/bin/find /var/backups -name 'laufzettel-*.dump' -mtime +30 -delete +# `root`, nicht `postgres`: der Timer sichert jetzt auch das Anhang-Verzeichnis, +# und das gehoert dem Dienstnutzer `laufzettel`. Der Datenbankteil laeuft +# weiterhin als `postgres`, ueber `runuser`. +User=root +ExecStart=/usr/local/sbin/laufzettel-sichern +ExecStartPost=/usr/bin/find /var/backups -name 'laufzettel-*' -mtime +30 -delete +``` + +`/usr/local/sbin/laufzettel-sichern`, Modus 0750: + +```bash +#!/bin/bash +# Datenbank und Anhaenge zum **selben** Zeitpunkt. Getrennt gesichert ergeben +# beide Haelften kein vollstaendiges Bild (#032). +set -Eeuo pipefail +tag=$(date +%F) +runuser -u postgres -- pg_dump -Fc laufzettel > "/var/backups/laufzettel-$tag.dump" + +# shellcheck source=/dev/null +. /etc/laufzettel/laufzettel.env +anhaenge="${ANHAENGE_PFAD:-/opt/laufzettel/daten/anhaenge}" +if [ -d "$anhaenge" ]; then + tar czf "/var/backups/laufzettel-anhaenge-$tag.tar.gz" -C "$anhaenge" . +fi ``` `/etc/systemd/system/laufzettel-sicherung.timer`: @@ -193,19 +269,22 @@ RandomizedDelaySec=15m WantedBy=timers.target ``` +Einrichten, einschalten, und **einmal von Hand auslösen** — eine Sicherung, die +nur im Timer steht, hat noch nie eine Datei erzeugt: + ```bash +sudo install -m 0750 laufzettel-sichern /usr/local/sbin/laufzettel-sichern sudo install -d -o postgres -g postgres -m 0700 /var/backups sudo systemctl daemon-reload sudo systemctl enable --now laufzettel-sicherung.timer +sudo systemctl start laufzettel-sicherung.service +ls -l /var/backups/laufzettel-* sudo systemctl list-timers laufzettel-sicherung.timer ``` -Einmal von Hand auslösen und nachsehen, ob wirklich eine Datei entstanden ist: - -```bash -sudo systemctl start laufzettel-sicherung.service -ls -l /var/backups/ -``` +Nach dem Lauf müssen **zwei** Dateien daliegen: `laufzettel-.dump` und +`laufzettel-anhaenge-.tar.gz`. Fehlt die zweite, gibt es entweder keine Anhänge, oder +`ANHAENGE_PFAD` zeigt woandershin als gedacht. ### Compose, als Cron-Eintrag @@ -218,6 +297,12 @@ In der Crontab von root (`sudo crontab -e`): Das `%` muss in einer Crontab als `\%` geschrieben werden, sonst schneidet cron die Zeile ab — der häufigste Fehler an dieser Stelle, und er fällt erst auf, wenn die Datei fehlt. +Und die Anhänge direkt danach, damit beide Hälften vom selben Zeitpunkt stammen: + +```cron +20 3 * * * docker run --rm -v laufzettel_anhaenge:/daten:ro -v /var/backups:/ziel debian:13 tar czf /ziel/laufzettel-anhaenge-$(date +\%F).tar.gz -C /daten . && find /var/backups -name 'laufzettel-anhaenge-*' -mtime +30 -delete +``` + ### Und dann weg vom Rechner Eine Sicherung, die auf derselben Platte liegt wie die Datenbank, hilft gegen einen Tippfehler und @@ -258,6 +343,21 @@ sudo -u postgres dropdb probe Kommt bei `count(*)` eine plausible Zahl heraus, ist die Sicherung brauchbar. Kommt eine Fehlermeldung oder eine Null, hast du es jetzt erfahren und nicht im Ernstfall. +### Und die Anhänge mitprüfen + +Die Zahl in der Datenbank gegen die Zahl der Dateien halten — passt sie nicht, fehlt eine der beiden +Hälften: + +```bash +# Compose: Zeilen in der Datenbank … +docker compose exec -T postgres psql -U laufzettel laufzettel -tAc 'SELECT count(*) FROM "Anhang"' +# … gegen Dateien im Archiv +tar tzf anhaenge-2026-09-24.tar.gz | grep -c '\.' || true +``` + +Stimmen die Zahlen nicht überein, ist entweder der Abzug älter als das Archiv oder umgekehrt. Beides +zum **selben Zeitpunkt** sichern — am einfachsten im selben Skript, direkt hintereinander. + ## Umzug auf einen neuen Server Derselbe Weg, nur mit einer Zwischenstation: @@ -284,6 +384,6 @@ Die PostgreSQL-Hauptfassung sollte auf dem Zielsystem dieselbe oder eine neuere | `ERROR: relation "Benutzer" already exists` | Du spielst in eine gefüllte Datenbank. Erst `dropdb`/`createdb`, siehe oben. | | `role "laufzettel" does not exist` beim Zurückspielen auf einem anderen System | Der Abzug nennt Eigentümer, die dort fehlen. `pg_restore --no-owner` beziehungsweise `pg_dump --no-owner` beim Erzeugen. | | Nach dem Zurückspielen schlägt der Verzeichnis-Abgleich fehl | `APP_SECRET` ist nicht mehr dasselbe, und das Bind-Passwort ist unlesbar. Entweder den alten Wert eintragen und neu starten, oder unter **Admin → Verzeichnis** das Bind-Passwort neu setzen. | -| Nach dem Zurückspielen fehlen Tabellen oder Spalten | Der Abzug ist älter als der Code. `prisma migrate deploy` nachziehen, siehe Schritt 4 oben. | +| Nach dem Zurückspielen fehlen Tabellen oder Spalten | Der Abzug ist älter als der Code. `prisma migrate deploy` nachziehen, siehe Schritt 5 oben. | | Die Anwendung startet nach dem Zurückspielen nicht | `docker compose logs web` beziehungsweise `journalctl -u laufzettel -n 50 --no-pager`. Meist läuft noch die Migration, oder sie ist gescheitert. | | Ein Tag-Code taucht doppelt auf | Kann nach einem Zurückspielen nicht passieren; ein partieller Eindeutigkeitsindex in der Datenbank verhindert es. Siehst du es doch, ist der Abzug nicht sauber eingespielt — Datenbank leeren und noch einmal von vorn. | diff --git a/docs/devlog.md b/docs/devlog.md index eef83cd..8f7f9aa 100644 --- a/docs/devlog.md +++ b/docs/devlog.md @@ -24,12 +24,19 @@ als Unterpunkte mit Aufgabe und Dauer. | #019 | M14 – Betriebswege und Release v0.1.0 | 5 h 25 min | | #025 | Healthcheck gibt die rohe Datenbankfehlermeldung heraus | 25 min | | #026 | Zwei Konventionen präzisieren | 20 min | -| | **Summe** | **36 h 05 min** | +| #020 | Vorgesetzter auch für von Hand angelegte Personen | 1 h 20 min | +| #021 | Stabiler Schlüssel für Seed-Vorlagen | 55 min | +| #022 | Schnellstart je Gerätetyp pflegen | 1 h 10 min | +| #023 | Protokoll ansehen | 1 h 50 min | +| #030 | M17 – Wiederverwendbare Bausteine | 2 h 25 min | +| #031 | M18 – Abteilungsspezifische Zusatzabschnitte | 1 h 35 min | +| #032 | M19 – Anhänge und Fotos an Schritten | 3 h 05 min | +| | **Summe** | **48 h 25 min** | | Meilenstein | Tatsächlich | Status | |---|---|---| | v0.1.0 | 35 h 20 min | veröffentlicht | -| v0.2.0 | 45 min | in Arbeit | +| v0.2.0 | 13 h 05 min | in Arbeit | --- @@ -1089,3 +1096,83 @@ Sechs Folge-Issues für `v0.2.0`: Vorgesetzter für von Hand angelegte Personen Schlüssel für Seed-Vorlagen (#021), Schnellstart je Gerätetyp pflegen (#022), Protokollansicht (#023). Dazu die Punkte, die der Abnahme-Review aufwirft. Nichts davon blockiert den Betrieb; alles davon steht geschrieben, statt verschwiegen zu werden. + +--- + +## 2026-10-04 · Sitzung 17 · der ganze Tag + +**Issues:** #021, #020, #022, #023, #046/#047, Phase-2-Plan, #030 (M17), #031 (M18), #032 (M19) +**Version:** 0.2.0 (unveröffentlicht) + +Eine lange Sitzung mit acht zusammengeführten Änderungen. Der Devlog-Eintrag kommt am Ende des Tages +und nicht nach jeder einzelnen — das ist der Grund, warum er alles auf einmal trägt. + +### Erledigt + +- **Der Rückstand für `v0.2.0` ist leer.** Stabiler Seed-Schlüssel (#021), Vorgesetzter für von Hand + angelegte Personen (#020), Schnellstart je Gerätetyp in der Oberfläche pflegen (#022), und das + Protokoll ist ansehbar geworden (#023) — geschrieben wurde es seit dem Datenmodell, ansehen konnte + es niemand. +- **Phase 2 in acht Meilensteine geschnitten** (#028–#035), mit drei Entscheidungen, die vor dem + ersten Codepfad fallen mussten. +- **M17 Bausteine** (#030): ein Abschnitt, den mehrere Vorlagen teilen, versioniert nach denselben + Regeln wie eine Vorlage. Eingesetzt wird er als tiefe Kopie beim **Veröffentlichen** — damit ändert + eine neue Baustein-Fassung keine veröffentlichte Vorlagenversion und keinen laufenden Lauf. +- **M18 Abteilungsabschnitte** (#031): ein Abschnitt kann sagen, für welche Abteilungen er gilt. + Welche Abschnitte ein Lauf hat, wird beim Start festgeschrieben. +- **M19 Anhänge** (#032): Fotos und Dateien an einem Schritt, am Handy direkt aus der Kamera. Dateien + im Dateisystem, Metadaten in der Datenbank, jeder Abruf prüft Recht und Mandant. +- **Sicherung und Rücksicherung der Anhänge wirklich gefahren** — Abzug gezogen, Datenbank verworfen, + Verzeichnis gelöscht, zurückgespielt, und die ausgelieferten Bytes gegen die gespeicherte + `pruefsumme` gehalten. +- 1246 Vitest-Tests (von 1130 am Morgen), alle Schranken grün. + +### Entscheidungen + +- **Ein Baustein wird beim Veröffentlichen aufgelöst, nicht beim Lesen.** Eine Auflösung zur Laufzeit + wäre weniger Code gewesen und hätte die Zusage „eine veröffentlichte Version wird nie verändert“ + gebrochen — über eine Hintertür, die niemand so nennt. +- **`Lauf.geltendeAbschnitte` ist leer heißt „alle“.** Damit verhalten sich Läufe von vor #031 + unverändert, ohne Migration von Daten. +- **Anhänge ins Dateisystem, nicht als `bytea`.** Ein `pg_dump` mit Fotos ist nach einem Jahr kein + Abzug mehr, den jemand einspielt. Der Preis ist die zweite Hälfte der Sicherung, und der steht + jetzt in `docs/betrieb/sicherung.md`. +- **Hochgeladen wird über eine Server Action, nicht über einen zweiten Endpunkt.** Das Issue hatte + einen Route Handler vorgesehen; der wäre eine zweite Tür mit eigener Rechteprüfung. Der Route + Handler bleibt für das Ausliefern, und er ruft `rechtErforderlich` selbst — die Hülle unter `(app)` + deckt Route Handler nicht ab. +- **Die Regel „geltende Abschnitte“ liegt in `src/lib`**, nicht in einem Lesemodell. Drei Schichten + brauchen sie; lebte sie in einem Lesemodell, importierte ein Dienst daraus. + +### Probleme + +- **Ein Fehler, den neunzehn grüne Tests nicht sehen konnten.** Der Schritt hat zwei Felder `datei` + in einem Formular — „Foto“ und „Datei“ —, abgeschickt wird immer beides, und `FormData.get` nahm + das erste, also das leere. Wer „Datei“ wählte, bekam „Keine Datei gewählt“, während „Foto“ ging. + Kein Dienst-Test schickt zwei Felder; gefunden hat es erst die Klickprobe am gebauten Stand. Die + Lehre ist dieselbe wie in #019: **Fehler im dokumentierten Weg eines Dritten sind für eine grüne + Suite unsichtbar.** Seitdem gibt es `tests/e2e/anhaenge.spec.ts`, der bewusst über den zweiten + Knopf hochlädt, und die Gegenprobe wurde gefahren: mit `get` statt `getAll` wird er rot. +- **`docs/betrieb/sicherung.md` enthielt einen falschen Satz**, nicht bloß eine Lücke: „Kein + Upload-Verzeichnis, keine erzeugten Dateien.“ Mit den Anhängen hätte eine Sicherung nach dieser + Anleitung still Daten verloren. Die Datei ist neu geschrieben. +- **Zweimal habe ich dem Produkt angelastet, was mein Prüfskript falsch machte** — ein zu weiter + `role="status"`-Selektor und eine Prüfung direkt nach `reload()`, als `networkidle` zu früh + zurückkam. Die Datenbank hat beide Male entschieden, und sie hat dem Produkt recht gegeben. +- **Einmal die Ursache falsch benannt.** Ich hielt eine `positionenSauber`-Prüfung für die Behebung + des Schnellstart-Fehlers; die Gegenprobe zeigte, dass die Tests ohne sie grün bleiben. Die + Behebung ist der deterministische Stichentscheid beim Sortieren. +- **Ein Numerusfehler in einem eigenen Hinweistext** („Ein Abschnitt gilt … und *bleiben* weg“), + gefunden beim Durchklicken. Alles, was ein Mensch liest, schreibt sich richtig. +- **`tests/datenbankregeln.test.ts` wurde rot** — genau wie gewollt: er zählt die handgeschriebenen + Datenbankobjekte namentlich auf, und die zwei neuen CHECK-Bedingungen und zwei zusammengesetzten + Fremdschlüssel standen nicht drin. +- **`/daten/` fehlte in `.gitignore`.** Ohne das läge das erste hochgeladene Foto im nächsten Commit. + +### Nächster Schritt + +- **M20 (#033)** Live-Aktualisierung per SSE statt Polling, dann **M21 (#034)** Benachrichtigungen, + **M22 (#035)** Selbstauskunft. **M15 (#028)** Graph-Quelle und **M16 (#029)** SCIM sind die beiden + großen verbliebenen. +- Weiter offen und nicht von hier aus machbar: **#024** Proxmox-Einzeiler auf einem echten PVE-Host. +- **#027** Die Editor-Abläufe lassen ihre Vorlagen in der Entwicklungsdatenbank stehen. diff --git a/docs/issues/032-anhaenge.md b/docs/issues/032-anhaenge.md index 2d748d7..4882eca 100644 --- a/docs/issues/032-anhaenge.md +++ b/docs/issues/032-anhaenge.md @@ -1,10 +1,10 @@ # #032 M19 – Anhänge und Fotos an Schritten -**Status:** offen +**Status:** erledigt **Im Tracker:** [#52](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/52) **Meilenstein:** v0.2.0 **Labels:** phase-2, backend, frontend, infra -**Geschätzt:** 10 h **Tatsächlich:** – +**Geschätzt:** 10 h **Tatsächlich:** 3 h 05 min ## Ziel @@ -14,7 +14,7 @@ können, am Handy direkt aus der Kamera. ## Zu entscheiden -- [ ] **Dateien im Dateisystem, Metadaten in der Datenbank** — Empfehlung. Ein Pfad aus der +- [x] **Dateien im Dateisystem, Metadaten in der Datenbank** — Empfehlung. Ein Pfad aus der Umgebung (`ANHAENGE_PFAD`), darunter nach Kunde und Lauf gegliedert. Gegen `bytea` in Postgres spricht die Sicherung: ein `pg_dump` mit Fotos ist nach einem Jahr kein `pg_dump` mehr, das jemand einspielt. Gegen S3 spricht, dass diese Anwendung selbst gehostet läuft und keinen @@ -23,32 +23,70 @@ können, am Handy direkt aus der Kamera. ein Volume, und `docs/betrieb/sicherung.md` muss beides nennen. Eine Sicherung, die nur die Datenbank mitnimmt, verliert ab hier Daten — das ist genau die Art Zusage, die dieses Projekt sonst teuer bezahlt hat. -- [ ] **Keine Verkleinerung auf dem Server im ersten Schritt.** Ein Handyfoto hat heute 3–6 MB; eine +- [x] **Keine Verkleinerung auf dem Server im ersten Schritt.** Ein Handyfoto hat heute 3–6 MB; eine Obergrenze (etwa 10 MB je Datei, 20 Dateien je Lauf) ist ehrlicher als eine Umwandlung, die Metadaten verliert und eine Bildbibliothek ins Projekt zieht. ## Aufgaben -- [ ] Modell `Anhang`: `schrittergebnisId`, `kundeId`, Dateiname, Medientyp, Größe, Prüfsumme, +- [x] Modell `Anhang`: `schrittergebnisId`, `kundeId`, Dateiname, Medientyp, Größe, Prüfsumme, `erstelltVonId`, `erstelltAm`; Datei daneben im Dateisystem -- [ ] Hochladen über einen Route Handler mit `rechtErforderlich` — kein Weg daran vorbei, und +- [x] Hochladen über einen Route Handler mit `rechtErforderlich` — kein Weg daran vorbei, und **jeder** Abruf prüft Recht und Mandant, auch der des Bildes selbst -- [ ] Grenzen: Medientypen auf Bilder und PDF, Größe je Datei und Anzahl je Lauf, beides in der +- [x] Grenzen: Medientypen auf Bilder und PDF, Größe je Datei und Anzahl je Lauf, beides in der Umgebung einstellbar; eine abgewiesene Datei nennt den Grund -- [ ] Am Handy direkt aus der Kamera (`capture="environment"`), am Rechner aus dem Dateiwähler -- [ ] **Der Archiv-Schreibschutz gilt auch hier**: an einem archivierten Lauf kommt kein Anhang mehr +- [x] Am Handy direkt aus der Kamera (`capture="environment"`), am Rechner aus dem Dateiwähler +- [x] **Der Archiv-Schreibschutz gilt auch hier**: an einem archivierten Lauf kommt kein Anhang mehr dazu und keiner weg. `laufMussVeraenderbarSein` sitzt an genau einer Stelle — dort anschließen -- [ ] Druckansicht: Bilder als Miniaturen mit Dateinamen, PDF nur als Zeile -- [ ] `docs/betrieb/sicherung.md`, `docker-compose.yml`, `deploy/install.sh`: Volume, Rechte, +- [x] Druckansicht: Bilder als Miniaturen mit Dateinamen, PDF nur als Zeile +- [x] `docs/betrieb/sicherung.md`, `docker-compose.yml`, `deploy/install.sh`: Volume, Rechte, Sicherung **und** Rückspielung -- [ ] Anleitung Kapitel 02 und 08 +- [x] Anleitung Kapitel 02 und 08 ## Akzeptanzkriterien -- [ ] Test: Anhang an einem Schritt hochladen, in der Abarbeitung und im Druck sichtbar -- [ ] Test: an einem archivierten Lauf wird das Hochladen abgewiesen -- [ ] Test: ein Anhang eines fremden Kunden ist über seine Adresse **nicht** abrufbar -- [ ] Test: eine zu große Datei und ein falscher Medientyp werden mit Grund abgewiesen -- [ ] Test: `docker-compose.yml` hat ein Volume für die Anhänge, und `sicherung.md` nennt es (wie +- [x] Test: Anhang an einem Schritt hochladen, in der Abarbeitung und im Druck sichtbar +- [x] Test: an einem archivierten Lauf wird das Hochladen abgewiesen +- [x] Test: ein Anhang eines fremden Kunden ist über seine Adresse **nicht** abrufbar +- [x] Test: eine zu große Datei und ein falscher Medientyp werden mit Grund abgewiesen +- [x] Test: `docker-compose.yml` hat ein Volume für die Anhänge, und `sicherung.md` nennt es (wie `tests/betriebsstand.test.ts` es für die Datenbank schon prüft) -- [ ] Sicherung und **Rückspielung** einmal wirklich gefahren, nicht nur beschrieben +- [x] Sicherung und **Rückspielung** einmal wirklich gefahren, nicht nur beschrieben + +## Beim Umsetzen dazugekommen + +- **Hochgeladen wird über eine Server Action, nicht über einen eigenen Route Handler.** Die Aufgabe + oben nannte einen Route Handler; der wäre eine zweite Tür mit eigener Rechteprüfung. Eine Server + Action prüft dasselbe Recht an derselben Stelle wie jede andere Schreiboperation am Lauf. Ein + Route Handler bleibt für das **Ausliefern** — dort braucht es eine Adresse, die ein `` holen + kann, und `src/app/api/anhaenge/[id]/route.ts` ruft selbst `rechtErforderlich("lesen")`, weil die + Hülle unter `(app)` Route Handler nicht abdeckt. +- **Der Pfad entsteht aus der Kennung, nie aus dem Dateinamen.** `///.`. + Der Name vom Browser wird trotzdem gesäubert, weil er in einer Antwortkopfzeile landet. +- **Kein SVG**, obwohl es ein Bild ist: ein SVG ist ein Dokument, das Skripte ausführt. Darum eine + Liste erlaubter Medientypen und kein `image/*`. +- **Reihenfolge beim Anlegen:** prüfen, Datei schreiben (`flag: "wx"`), dann die Zeile — und bei + einem Fehler die Datei wieder weg. Eine Datei ohne Zeile findet niemand; eine Zeile ohne Datei ist + ein Anhang, der beim Öffnen fehlschlägt. Beim Entfernen umgekehrt: erst die Zeile, dann die Datei. +- **Zwei Felder `datei` in einem Formular — und `FormData.get` nahm das falsche.** „Foto“ und + „Datei“ stehen im selben Formular, abgeschickt wird immer beides, und das leere Feld kam zuerst: + wer „Datei“ wählte, bekam „Keine Datei gewählt“, während „Foto“ ging. Neunzehn grüne Dienst-Tests + sahen davon nichts, weil kein Dienst-Test zwei Felder schickt. Gefunden hat es erst die Klickprobe + am gebauten Stand. Die Regel liegt jetzt als reine Funktion `gewaehlteDatei()` in `src/lib`, und + `tests/e2e/anhaenge.spec.ts` lädt bewusst **über den zweiten Knopf** hoch. Gegenprobe gefahren: + mit `get` statt `getAll` wird der Ablauf rot. +- **`docs/betrieb/sicherung.md` war nicht nur unvollständig, sondern an einer Stelle falsch** — dort + stand „Kein Upload-Verzeichnis, keine erzeugten Dateien“. Die Datei ist neu geschrieben: drei + Dinge statt zwei, `tar` für beide Betriebswege, der `chown`-Fallstrick nach einer Rücksicherung als + `root`, ein Sicherungsskript für den systemd-Timer, das Datenbank und Anhänge im **selben** Lauf + mitnimmt, und die Gegenprobe über die Zahlen. +- **Sicherung und Rücksicherung wirklich gefahren**, nicht beschrieben: Abzug gezogen, Datenbank + verworfen und neu angelegt, Verzeichnis gelöscht, erst nur die Datenbank zurückgespielt — und da + meldete der Abruf genau den dokumentierten Satz „Die Datei zu … fehlt im Dateisystem“ —, dann die + Anhänge dazu, und die ausgelieferten Bytes gegen die gespeicherte `pruefsumme` geprüft. Beide + stimmten. +- **`tests/datenbankregeln.test.ts` musste nachziehen.** Er zählt die handgeschriebenen + Datenbankobjekte namentlich auf; die zwei neuen CHECK-Bedingungen und die beiden zusammengesetzten + Fremdschlüssel standen nicht drin, und der Test wurde dadurch rot — genau wie gewollt. +- **`/daten/` ist jetzt in `.gitignore`.** Ohne das läge das erste hochgeladene Foto im nächsten + Commit. diff --git a/docs/issues/README.md b/docs/issues/README.md index 5b66f0f..abdcd6d 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -36,8 +36,8 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac | #029 | [M16 – SCIM-Endpunkt als Alternative zum Abgleich](029-scim-endpunkt.md) | offen | v0.2.0 | 12 h | – | | #030 | [M17 – Wiederverwendbare Bausteine](030-bausteine.md) | erledigt | v0.2.0 | 12 h | 2 h 25 min | | #031 | [M18 – Abteilungsspezifische Zusatzabschnitte](031-abteilungsabschnitte.md) | erledigt | v0.2.0 | 8 h | 1 h 35 min | -| #032 | [M19 – Anhänge und Fotos an Schritten](032-anhaenge.md) | offen | v0.2.0 | 10 h | – | +| #032 | [M19 – Anhänge und Fotos an Schritten](032-anhaenge.md) | erledigt | v0.2.0 | 10 h | 3 h 05 min | | #033 | [M20 – Live-Aktualisierung per SSE statt Polling](033-sse-live.md) | offen | v0.2.0 | 6 h | – | | #034 | [M21 – Benachrichtigungen per Mail und Webhook](034-benachrichtigungen.md) | offen | v0.2.0 | 10 h | – | | #035 | [M22 – Mitarbeiter-Selbstauskunft](035-selbstauskunft.md) | offen | v0.2.0 | 8 h | – | -| | **Summe** | | | **229 h** | **45 h 20 min** | +| | **Summe** | | | **229 h** | **48 h 25 min** | diff --git a/prisma/migrations/20261004220000_anhaenge/migration.sql b/prisma/migrations/20261004220000_anhaenge/migration.sql new file mode 100644 index 0000000..534fa5d --- /dev/null +++ b/prisma/migrations/20261004220000_anhaenge/migration.sql @@ -0,0 +1,81 @@ +-- --------------------------------------------------------------------------- +-- HANDGESCHRIEBEN. Prisma erzeugt hier zusaetzlich Loeschbefehle fuer die +-- zusammengesetzten Fremdschluessel (Mandantengrenze), die Trigramm-Indizes +-- (Archivsuche), die partiellen eindeutigen Indizes und die Trigger auf +-- veroeffentlichten Versionen: es kennt sie nicht, weil sie sich im Schema nicht +-- ausdruecken lassen. Diese Loeschbefehle sind bewusst gestrichen. +-- +-- tests/datenbankregeln.test.ts prueft, dass alle diese Objekte vorhanden sind – +-- ein versehentlich stehengelassener Loeschbefehl faellt dort sofort auf. +-- --------------------------------------------------------------------------- + +-- Anhaenge und Fotos an Schritten (#032). +-- +-- Die Datei liegt im Dateisystem unter ANHAENGE_PFAD, hier stehen die +-- Metadaten. Gegen bytea spricht die Sicherung: ein pg_dump mit Fotos ist nach +-- einem Jahr keines, das jemand einspielt. + +CREATE TABLE "Anhang" ( + "id" TEXT NOT NULL, + "schrittergebnisId" TEXT NOT NULL, + "laufId" TEXT NOT NULL, + "kundeId" TEXT NOT NULL, + "dateiname" TEXT NOT NULL, + "medientyp" TEXT NOT NULL, + "groesse" INTEGER NOT NULL, + "pruefsumme" TEXT NOT NULL, + "erstelltVonId" TEXT, + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Anhang_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "Anhang_schrittergebnisId_idx" ON "Anhang"("schrittergebnisId"); +CREATE INDEX "Anhang_laufId_idx" ON "Anhang"("laufId"); +CREATE INDEX "Anhang_kundeId_idx" ON "Anhang"("kundeId"); + +-- Schluesselkandidaten fuer die Mandantengrenze unten. Lauf hatte bisher keinen – +-- die vier bestehenden zusammengesetzten Fremdschluessel zeigen auf Geraet, +-- Mitarbeiter und Abteilung, nicht auf Lauf. +CREATE UNIQUE INDEX "Schrittergebnis_id_laufId_key" ON "Schrittergebnis"("id", "laufId"); +CREATE UNIQUE INDEX "Lauf_id_kundeId_key" ON "Lauf"("id", "kundeId"); + +ALTER TABLE "Anhang" + ADD CONSTRAINT "Anhang_schrittergebnisId_fkey" + FOREIGN KEY ("schrittergebnisId") REFERENCES "Schrittergebnis"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "Anhang" + ADD CONSTRAINT "Anhang_laufId_fkey" + FOREIGN KEY ("laufId") REFERENCES "Lauf"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "Anhang" + ADD CONSTRAINT "Anhang_kundeId_fkey" + FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "Anhang" + ADD CONSTRAINT "Anhang_erstelltVonId_fkey" + FOREIGN KEY ("erstelltVonId") REFERENCES "Benutzer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- ---- Mandantengrenze und Zusammenhalt -------------------------------------- +-- +-- Dieselbe Bauart wie bei Lauf und Geraet: ein Anhang kann strukturell nicht zu +-- einem Schrittergebnis eines anderen Laufs und nicht zu einem anderen Kunden +-- als sein Lauf gehoeren. +ALTER TABLE "Anhang" + ADD CONSTRAINT "Anhang_ergebnis_gleicher_lauf" + FOREIGN KEY ("schrittergebnisId", "laufId") REFERENCES "Schrittergebnis" ("id", "laufId") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "Anhang" + ADD CONSTRAINT "Anhang_lauf_gleicher_kunde" + FOREIGN KEY ("laufId", "kundeId") REFERENCES "Lauf" ("id", "kundeId") + ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Eine Groesse von 0 waere eine leere Datei, und eine negative gibt es nicht. +ALTER TABLE "Anhang" + ADD CONSTRAINT "Anhang_groesse_positiv" CHECK ("groesse" > 0); + +-- SHA-256 als Hex: 64 Zeichen, nur Ziffern und a-f. Eine Pruefsumme, die keine +-- ist, wuerde nach einer Ruecksicherung nichts beantworten. +ALTER TABLE "Anhang" + ADD CONSTRAINT "Anhang_pruefsumme_form" CHECK ("pruefsumme" ~ '^[0-9a-f]{64}$'); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c96f83e..b40e7ac 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -134,6 +134,7 @@ model Kunde { benutzerbindungen BenutzerKunde[] geraete Geraet[] laeufe Lauf[] + anhaenge Anhang[] mitarbeiter Mitarbeiter[] protokolle Protokoll[] verzeichnisquellen Verzeichnisquelle[] @@ -172,6 +173,7 @@ model Benutzer { protokolle Protokoll[] schrittergebnisse Schrittergebnis[] veroeffentlichteVersionen VorlagenVersion[] @relation("VersionVeroeffentlichtVon") + anhaenge Anhang[] @@index([aktiv]) @@index([rolle]) @@ -635,7 +637,10 @@ model Lauf { ergebnisse Schrittergebnis[] kommentare Kommentar[] protokolle Protokoll[] + anhaenge Anhang[] + /// Schluesselkandidat fuer den zusammengesetzten Fremdschluessel von Anhang. + @@unique([id, kundeId]) @@index([kundeId, status]) @@index([geraetId, status]) @@index([mitarbeiterId]) @@ -665,11 +670,52 @@ model Schrittergebnis { /// Codepfad darf annehmen, dass immer ein Mensch dahintersteht. aktualisiertVon Benutzer? @relation(fields: [aktualisiertVonId], references: [id], onDelete: Restrict) kommentare Kommentar[] + anhaenge Anhang[] @@unique([laufId, vorlagenSchrittId]) + /// Schluesselkandidat fuer den zusammengesetzten Fremdschluessel von Anhang. + @@unique([id, laufId]) @@index([laufId, zustand]) } +/// Eine Datei oder ein Foto an einem Schritt (#032). +/// +/// Die Datei selbst liegt im Dateisystem unter `ANHAENGE_PFAD`; hier stehen nur +/// die Metadaten. Gegen `bytea` spricht die Sicherung: ein `pg_dump` mit Fotos +/// ist nach einem Jahr kein `pg_dump` mehr, das jemand einspielt. **Folge:** die +/// Sicherung muss Datenbank **und** Verzeichnis mitnehmen, siehe +/// `docs/betrieb/sicherung.md`. +model Anhang { + id String @id @default(cuid()) + schrittergebnisId String + laufId String + /// Jede kundenbezogene Zeile traegt kundeId (CLAUDE.md). Die Mandantengrenze + /// steht zusaetzlich als zusammengesetzter Fremdschluessel in der Migration. + kundeId String + /// Der Name, den der Browser mitgeschickt hat – nur zur Anzeige. Der Pfad auf + /// der Platte entsteht aus `id`, damit kein Dateiname von aussen einen Pfad + /// bestimmt. + dateiname String + medientyp String + groesse Int + /// SHA-256 der Datei. Beantwortet nach einer Ruecksicherung die Frage, ob die + /// Datei noch die ist, die hier beschrieben steht. + pruefsumme String + erstelltVonId String? + erstelltAm DateTime @default(now()) + + schrittergebnis Schrittergebnis @relation(fields: [schrittergebnisId], references: [id], onDelete: Cascade) + lauf Lauf @relation(fields: [laufId], references: [id], onDelete: Restrict) + kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict) + /// Leer bei einem Systemlauf – kein Codepfad nimmt an, dass ein Mensch + /// dahintersteht. + erstelltVon Benutzer? @relation(fields: [erstelltVonId], references: [id], onDelete: Restrict) + + @@index([schrittergebnisId]) + @@index([laufId]) + @@index([kundeId]) +} + model Kommentar { id String @id @default(cuid()) laufId String diff --git a/src/app/(app)/laeufe/[id]/page.tsx b/src/app/(app)/laeufe/[id]/page.tsx index 4321681..b4d2d0a 100644 --- a/src/app/(app)/laeufe/[id]/page.tsx +++ b/src/app/(app)/laeufe/[id]/page.tsx @@ -15,10 +15,12 @@ import { LaufMitAdKontoVerknuepfen, LaufZurueckholen, } from "@/components/laeufe/lauf-aktionen"; +import { SchrittAnhaenge, type SchrittAnhang } from "@/components/laeufe/schritt-anhaenge"; import { SchrittWert } from "@/components/laeufe/schritt-wert"; import { SchrittZustandWaehlen, ZustandKennzeichen } from "@/components/laeufe/schritt-zustand"; import { darf } from "@/server/auth/rollen"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { anhaengeDesLaufs } from "@/server/services/anhaenge"; import { protokollDesLaufs } from "@/server/queries/protokoll"; import { kommentareDesLaufs, @@ -82,12 +84,14 @@ function Schritt({ lesend, ausfuehrlich, kommentare, + anhaenge, }: { schritt: AbarbeitungsSchritt; laufId: string; lesend: boolean; ausfuehrlich: boolean; kommentare: readonly KommentarZeile[]; + anhaenge: readonly SchrittAnhang[]; }) { const vomTisch = schritt.zustand === SchrittZustand.ERLEDIGT || schritt.zustand === SchrittZustand.NICHT_ZUTREFFEND; @@ -197,6 +201,22 @@ function Schritt({ ) : null} + {/* + * Anhaenge (#032). Immer sichtbar, wenn es welche gibt — auch an einem + * archivierten Lauf, nur dann ohne Knoepfe. Ein Foto vom Typenschild + * ist Teil des Belegs. + */} + {anhaenge.length > 0 || (!lesend && schritt.ergebnisId) ? ( +
+ +
+ ) : null} + {lesend || !schritt.ergebnisId ? null : (
@@ -220,12 +240,14 @@ function Abschnitt({ lesend, ausfuehrlich, kommentare, + anhaenge, }: { abschnitt: AbarbeitungsAbschnitt; laufId: string; lesend: boolean; ausfuehrlich: boolean; kommentare: readonly KommentarZeile[]; + anhaenge: readonly (SchrittAnhang & { readonly schrittergebnisId: string })[]; }) { const erledigt = abschnitt.schritte.filter( (schritt) => @@ -263,6 +285,7 @@ function Abschnitt({ lesend={lesend} ausfuehrlich={ausfuehrlich} kommentare={kommentare.filter((kommentar) => kommentar.schrittergebnisId === schritt.ergebnisId)} + anhaenge={anhaenge.filter((anhang) => anhang.schrittergebnisId === schritt.ergebnisId)} /> ))} @@ -297,6 +320,9 @@ export default async function LaufSeite({ // Die Sicht hat `laufHolen` oben schon angewandt; hier geht es nur noch um die // Einträge dieses Laufs (#023). const laufProtokoll = await protokollDesLaufs(id); + // Die Sicht hat `laufHolen` oben schon angewandt; hier nur die Metadaten der + // Anhaenge dieses Laufs (#032). + const anhaenge = await anhaengeDesLaufs(id); /* * Die Personenliste nur fuer den Onboarding-Fall holen — sonst stuende in * jedem Seitenaufbau eine Abfrage, die niemand braucht. @@ -394,6 +420,7 @@ export default async function LaufSeite({ lesend={lesend} ausfuehrlich={ausfuehrlich} kommentare={kommentare} + anhaenge={anhaenge} /> ))} diff --git a/src/app/api/anhaenge/[id]/route.ts b/src/app/api/anhaenge/[id]/route.ts new file mode 100644 index 0000000..31d9aa2 --- /dev/null +++ b/src/app/api/anhaenge/[id]/route.ts @@ -0,0 +1,61 @@ +import { AnhangFehler, anhangLesen } from "@/server/services/anhaenge"; +import { MandantFehler } from "@/server/mandant"; +import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht"; +import { istBild } from "@/lib/anhaenge"; + +/** + * Einen Anhang ausliefern (#032). + * + * **Route Handler prüfen selbst.** Die Hülle unter `src/app/(app)/layout.tsx` + * deckt sie nicht ab, und der Proxy sieht nur, **ob** ein Cookie da ist, nie was + * drinsteht (CLAUDE.md). Hier zählt das doppelt: eine Adresse, die eine Datei + * ohne Prüfung ausliefert, umgeht alles andere. + * + * `Content-Disposition: inline` nur für Bilder. Ein PDF wird **heruntergeladen** + * statt im Browser gerendert: ein PDF kann Skripte enthalten, und ein Anhang, + * den jemand aus einem fremden Lauf öffnet, soll nichts ausführen. + */ +export async function GET( + _anfrage: Request, + { params }: { params: Promise<{ id: string }> }, +): Promise { + let benutzer: { id: string; rolle: Parameters[1]["rolle"] }; + try { + const zugang = await rechtErforderlich("lesen"); + benutzer = { id: zugang.benutzer.id, rolle: zugang.benutzer.rolle }; + } catch (fehler) { + if (fehler instanceof RechteFehler) { + return Response.json({ fehler: "Nicht angemeldet." }, { status: 401 }); + } + throw fehler; + } + + const { id } = await params; + try { + const datei = await anhangLesen(id, benutzer); + // Der Dateiname steckt in einer Kopfzeile. `dateinameSaeubern` hat + // Steuerzeichen und Anführungszeichen schon beim Anlegen entfernt. + const anzeige = istBild(datei.medientyp) ? "inline" : "attachment"; + return new Response(new Uint8Array(datei.inhalt), { + headers: { + "Content-Type": datei.medientyp, + "Content-Length": String(datei.groesse), + "Content-Disposition": `${anzeige}; filename="${datei.dateiname}"`, + // Privat: ein Anhang gehört einem Kunden, kein Zwischenspeicher darf ihn + // an den nächsten Betrachter weitergeben. + "Cache-Control": "private, max-age=0, must-revalidate", + "X-Content-Type-Options": "nosniff", + }, + }); + } catch (fehler) { + if (fehler instanceof MandantFehler) { + // Nicht vorhanden und nicht sichtbar sind dieselbe Antwort – die + // Unterscheidung wäre schon eine Auskunft über fremde Kunden. + return Response.json({ fehler: "Diesen Anhang gibt es nicht." }, { status: 404 }); + } + if (fehler instanceof AnhangFehler) { + return Response.json({ fehler: fehler.message }, { status: 404 }); + } + throw fehler; + } +} diff --git a/src/components/druck/lauf-beleg.tsx b/src/components/druck/lauf-beleg.tsx index 6bd6030..dfcf1ce 100644 --- a/src/components/druck/lauf-beleg.tsx +++ b/src/components/druck/lauf-beleg.tsx @@ -1,6 +1,7 @@ import { Check, CircleSlash, Square, TriangleAlert, type LucideIcon } from "lucide-react"; import { LaufStatus, SchrittZustand } from "@/generated/prisma/enums"; import { Wortmarke } from "@/components/wortmarke"; +import { groesseText, istBild } from "@/lib/anhaenge"; import type { DruckAbschnitt, DruckKopf, DruckSchritt, LaufZumDrucken } from "@/server/queries/druck"; import type { KommentarZeile } from "@/server/queries/laeufe"; @@ -173,6 +174,51 @@ function Kommentare({ kommentare }: { kommentare: readonly KommentarZeile[] }) { ); } +/** + * Anhaenge auf dem Ausdruck (#032). + * + * Bilder als Miniatur, ein PDF nur als Zeile — ein eingebettetes PDF liesse sich + * auf Papier ohnehin nicht ansehen. Die Datei kommt ueber ihre Adresse, nicht + * eingebettet: der Ausdruck entsteht im Browser des Benutzers, und dessen + * Sitzung laesst den Abruf ohnehin pruefen. + */ +function Anhaenge({ anhaenge }: { anhaenge: DruckSchritt["anhaenge"] }) { + if (anhaenge.length === 0) return null; + const bilder = anhaenge.filter((anhang) => istBild(anhang.medientyp)); + const dateien = anhaenge.filter((anhang) => !istBild(anhang.medientyp)); + + return ( +
+ {bilder.length > 0 ? ( +
    + {bilder.map((anhang) => ( +
  • + {/* Ohne next/image: der Optimierer holte die Datei erneut und ohne + Rechteprüfung. */} + {/* eslint-disable-next-line @next/next/no-img-element */} + {anhang.dateiname} + {anhang.dateiname} +
  • + ))} +
+ ) : null} + {dateien.length > 0 ? ( +
    + {dateien.map((anhang) => ( +
  • + Anhang: {anhang.dateiname} ({groesseText(anhang.groesse)}) +
  • + ))} +
+ ) : null} +
+ ); +} + function Schritt({ schritt }: { schritt: DruckSchritt }) { const Symbol = ZUSTAND_SYMBOL[schritt.zustand]; return ( @@ -204,6 +250,7 @@ function Schritt({ schritt }: { schritt: DruckSchritt }) {

) : null} + diff --git a/src/components/laeufe/schritt-anhaenge.tsx b/src/components/laeufe/schritt-anhaenge.tsx new file mode 100644 index 0000000..d080428 --- /dev/null +++ b/src/components/laeufe/schritt-anhaenge.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useActionState, useRef, useState } from "react"; +import { Camera, FileText, Paperclip, X } from "lucide-react"; +import { ERLAUBTE_MEDIENTYPEN, groesseText, istBild, maxGroesseBytes } from "@/lib/anhaenge"; +import { + anhangEntfernenAktion, + anhangHochladenAktion, + type AktionsErgebnis, +} from "@/server/actions/anhaenge"; + +/** + * Anhaenge eines Schritts (#032). + * + * Zwei Wege zum Anhaengen, weil zwei Geraete gemeint sind: **Foto** oeffnet am + * Handy direkt die Kamera (`capture="environment"`), **Datei** den Dateiwaehler. + * Am Rechner tun beide dasselbe — dort gibt es keine Kamera-App, und der Browser + * zeigt in beiden Faellen den Waehler. + * + * Abgeschickt wird sofort nach der Wahl, ohne zweiten Knopf: ein Foto, das man + * gerade gemacht hat, will man nicht noch bestaetigen. Ein eigener + * „Hochladen“-Knopf waere der Schritt, den man vergisst. + */ + +export type SchrittAnhang = { + readonly id: string; + readonly dateiname: string; + readonly medientyp: string; + readonly groesse: number; + readonly erstelltVon: string | null; +}; + +export function SchrittAnhaenge({ + laufId, + vorlagenSchrittId, + anhaenge, + lesend, +}: { + laufId: string; + vorlagenSchrittId: string; + anhaenge: readonly SchrittAnhang[]; + lesend: boolean; +}) { + const [hochladen, hochladenAktion, laedt] = useActionState( + anhangHochladenAktion, + {}, + ); + const [entfernen, entfernenAktion] = useActionState(anhangEntfernenAktion, {}); + const formular = useRef(null); + const [zuGross, setzeZuGross] = useState(null); + + function gewaehlt(ereignis: React.ChangeEvent): void { + const datei = ereignis.target.files?.[0]; + if (!datei) return; + /* + * Die Groesse schon hier pruefen. Sonst wandern bei einem Fehlgriff 200 MB + * durch die Leitung, nur damit der Server sie ablehnt — am Handy im + * Mobilfunk ist das der Unterschied zwischen einer Meldung und einer + * Minute Warten. Dienst und Action pruefen es trotzdem noch einmal. + */ + if (datei.size > maxGroesseBytes()) { + setzeZuGross( + `„${datei.name}“ ist ${groesseText(datei.size)} groß. Erlaubt sind höchstens ` + + `${groesseText(maxGroesseBytes())}.`, + ); + ereignis.target.value = ""; + return; + } + setzeZuGross(null); + formular.current?.requestSubmit(); + } + + const meldung = zuGross ?? hochladen.fehler ?? entfernen.fehler; + + return ( +
+ {anhaenge.length > 0 ? ( + + ) : null} + + {lesend ? null : ( +
+ + + + + + + + {laedt ? wird hochgeladen … : null} +
+ )} + + {meldung ? ( +

+ {meldung} +

+ ) : null} +
+ ); +} diff --git a/src/lib/anhaenge.ts b/src/lib/anhaenge.ts new file mode 100644 index 0000000..3cc39b1 --- /dev/null +++ b/src/lib/anhaenge.ts @@ -0,0 +1,111 @@ +/** + * Grenzen und Formen der Anhaenge (#032). + * + * Framework-frei, weil beide Seiten davon lesen: der Dienst prueft damit, und + * die Oberflaeche sagt damit **vorher**, was erlaubt ist. Stuenden die Grenzen + * nur im Dienst, waere jede Abweisung eine Ueberraschung. + */ + +/** + * Was sich anhaengen laesst: Bilder und PDF. + * + * Bewusst eine Liste und kein Praefix-Vergleich wie `image/*`: ein Browser + * schickt auch `image/svg+xml`, und SVG ist ein Dokument mit Skripten, das ein + * Betrachter ausfuehrt. Ein Anhang, den jemand oeffnet, darf kein Angriffsweg + * sein. + */ +export const ERLAUBTE_MEDIENTYPEN = [ + "image/jpeg", + "image/png", + "image/webp", + "image/heic", + "image/heif", + "application/pdf", +] as const; + +export type Medientyp = (typeof ERLAUBTE_MEDIENTYPEN)[number]; + +/** Die Endung, unter der eine Datei auf der Platte liegt. */ +export const ENDUNG_JE_TYP: Readonly> = { + "image/jpeg": "jpg", + "image/png": "png", + "image/webp": "webp", + "image/heic": "heic", + "image/heif": "heif", + "application/pdf": "pdf", +}; + +export function istErlaubterMedientyp(typ: string): typ is Medientyp { + return (ERLAUBTE_MEDIENTYPEN as readonly string[]).includes(typ); +} + +/** Bilder werden als Miniatur gezeigt, ein PDF nur als Zeile. */ +export function istBild(medientyp: string): boolean { + return medientyp !== "application/pdf" && istErlaubterMedientyp(medientyp); +} + +const STANDARD_MAX_MB = 10; +const STANDARD_JE_LAUF = 20; + +/** + * Hoechstgroesse je Datei in Bytes. + * + * Ein Handyfoto hat heute 3–6 MB. Zehn als Vorgabe lassen Luft, ohne dass eine + * einzelne Datei eine Sicherung sprengt. Keine serverseitige Verkleinerung: + * sie verliert Metadaten und zieht eine Bildbibliothek ins Projekt — eine + * ehrliche Obergrenze ist weniger Magie. + */ +export function maxGroesseBytes(): number { + const roh = Number.parseInt(process.env["ANHANG_MAX_MB"]?.trim() ?? "", 10); + const mb = Number.isFinite(roh) && roh > 0 ? roh : STANDARD_MAX_MB; + return mb * 1024 * 1024; +} + +/** Hoechstzahl Anhaenge je Lauf. */ +export function maxJeLauf(): number { + const roh = Number.parseInt(process.env["ANHAENGE_JE_LAUF"]?.trim() ?? "", 10); + return Number.isFinite(roh) && roh > 0 ? roh : STANDARD_JE_LAUF; +} + +/** Wo die Dateien liegen. Ohne Angabe ein Verzeichnis im Arbeitsbaum. */ +export function anhaengePfad(): string { + return process.env["ANHAENGE_PFAD"]?.trim() || "./daten/anhaenge"; +} + +/** + * Die eine gewaehlte Datei aus mehreren Formularfeldern desselben Namens. + * + * Der Schritt hat **zwei** Felder `datei` in einem Formular: „Foto“ mit + * `capture="environment"` und „Datei“ mit dem Dateiwaehler. Abgeschickt wird + * immer das ganze Formular, also kommen beide Felder mit — das leere als + * `File` mit `size === 0`. `FormData.get("datei")` liefert das **erste**, und + * das war in #032 das leere: wer „Datei“ nutzte, bekam „Keine Datei gewählt“, + * waehrend „Foto“ ging. Gefunden erst in der Klickprobe am gebauten Stand, weil + * kein Dienst-Test zwei Felder schickt. + * + * Darum hier die Regel, und zwar als reine Funktion: das **erste nicht leere** + * Feld gewinnt, unabhaengig von der Reihenfolge im Formular. + */ +export function gewaehlteDatei(eintraege: readonly unknown[]): File | undefined { + return eintraege.find((eintrag): eintrag is File => eintrag instanceof File && eintrag.size > 0); +} + +/** `4,2 MB` – fuer Meldungen und die Anzeige. */ +export function groesseText(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1).replace(".", ",")} MB`; +} + +/** + * Ein Dateiname, der sich anzeigen laesst. + * + * Der Name vom Browser wird **nie** zum Pfad — der entsteht aus der `id`. + * Trotzdem wird er beschnitten und von Steuerzeichen befreit: er landet in einer + * Antwortkopfzeile und in der Oberflaeche. + */ +export function dateinameSaeubern(roh: string): string { + const ohnePfad = roh.split(/[\\/]/).pop() ?? ""; + const sauber = ohnePfad.replace(/[\u0000-\u001f\u007f"]/g, "").trim(); + return (sauber || "anhang").slice(0, 200); +} diff --git a/src/server/actions/anhaenge.ts b/src/server/actions/anhaenge.ts new file mode 100644 index 0000000..b7eb0e5 --- /dev/null +++ b/src/server/actions/anhaenge.ts @@ -0,0 +1,109 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "@/lib/validierung"; +import { gewaehlteDatei, groesseText, maxGroesseBytes } from "@/lib/anhaenge"; +import { MandantFehler } from "@/server/mandant"; +import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht"; +import { LaufFehler } from "@/server/services/laeufe"; +import { AnhangFehler, anhangEntfernen, anhangSpeichern } from "@/server/services/anhaenge"; + +/** + * Server Actions der Anhaenge (#032). + * + * Recht ist `laeufeBearbeiten` — wer einen Schritt abhaken darf, darf auch ein + * Foto dazu legen. Der Archiv-Schreibschutz sitzt im Dienst, an derselben + * Stelle wie fuer alles andere am Lauf. + * + * Die Datei kommt als `File` im Formular. Server Actions nehmen das von sich + * aus; ein eigener Upload-Endpunkt waere eine zweite Tuer mit eigener + * Rechtepruefung. + */ + +export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: string }; + +const RECHTE_MELDUNG = "Dafür fehlt dir die Berechtigung."; + +function alsErgebnis(fehler: unknown, neutral: string): AktionsErgebnis { + if (fehler instanceof AnhangFehler || fehler instanceof LaufFehler) { + return { fehler: fehler.message }; + } + if (fehler instanceof MandantFehler) return { fehler: fehler.message }; + if (fehler instanceof RechteFehler) return { fehler: RECHTE_MELDUNG }; + console.error( + `${neutral}:`, + fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : "unbekannter Fehler", + ); + return { fehler: neutral }; +} + +export async function anhangHochladenAktion( + _vorher: AktionsErgebnis, + formular: FormData, +): Promise { + try { + const { benutzer } = await rechtErforderlich("laeufeBearbeiten"); + + const eingabe = z.object({ laufId: z.string().min(1), vorlagenSchrittId: z.string().min(1) }).safeParse({ + laufId: formular.get("laufId"), + vorlagenSchrittId: formular.get("vorlagenSchrittId"), + }); + if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." }; + + // `getAll`, nicht `get`: das Formular hat zwei Felder `datei` – siehe + // `gewaehlteDatei`. + const datei = gewaehlteDatei(formular.getAll("datei")); + if (!datei) { + return { fehler: "Keine Datei gewählt." }; + } + /* + * Die Groesse **vor** dem Lesen pruefen. `arrayBuffer()` zieht die ganze + * Datei in den Speicher; bei einer 500-MB-Datei waere die Pruefung danach zu + * spaet. Der Dienst prueft es noch einmal — er ist auch ohne diese Action + * erreichbar. + */ + const grenze = maxGroesseBytes(); + if (datei.size > grenze) { + return { + fehler: + `Die Datei ist ${groesseText(datei.size)} groß. Erlaubt sind höchstens ` + + `${groesseText(grenze)} je Datei.`, + }; + } + + await anhangSpeichern( + { + laufId: eingabe.data.laufId, + vorlagenSchrittId: eingabe.data.vorlagenSchrittId, + dateiname: datei.name, + medientyp: datei.type, + inhalt: new Uint8Array(await datei.arrayBuffer()), + }, + { id: benutzer.id, rolle: benutzer.rolle }, + ); + + revalidatePath(`/laeufe/${eingabe.data.laufId}`); + return { erfolg: "Angehängt." }; + } catch (fehler) { + return alsErgebnis(fehler, "Der Anhang konnte nicht gespeichert werden."); + } +} + +export async function anhangEntfernenAktion( + _vorher: AktionsErgebnis, + formular: FormData, +): Promise { + try { + const { benutzer } = await rechtErforderlich("laeufeBearbeiten"); + const eingabe = z + .object({ anhangId: z.string().min(1), laufId: z.string().min(1) }) + .safeParse({ anhangId: formular.get("anhangId"), laufId: formular.get("laufId") }); + if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." }; + + await anhangEntfernen(eingabe.data.anhangId, { id: benutzer.id, rolle: benutzer.rolle }); + revalidatePath(`/laeufe/${eingabe.data.laufId}`); + return { erfolg: "Entfernt." }; + } catch (fehler) { + return alsErgebnis(fehler, "Der Anhang konnte nicht entfernt werden."); + } +} diff --git a/src/server/queries/druck.ts b/src/server/queries/druck.ts index 60c7e3a..916e8ef 100644 --- a/src/server/queries/druck.ts +++ b/src/server/queries/druck.ts @@ -1,6 +1,7 @@ import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums"; import { prisma } from "@/lib/db"; import { nurGeltende } from "@/lib/geltende-abschnitte"; +import { anhaengeDesLaufs } from "@/server/services/anhaenge"; import { platzhalterErsetzen } from "@/lib/platzhalter-werte"; import { kundenEbeneSichtbar, sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant"; import { kommentareDesLaufs, type KommentarZeile } from "@/server/queries/laeufe"; @@ -87,6 +88,13 @@ export type DruckSchritt = { readonly bearbeitetVon: string | null; readonly bearbeitetAm: Date | null; readonly kommentare: readonly KommentarZeile[]; + /** Anhaenge an diesem Schritt (#032). Bilder als Miniatur, PDF als Zeile. */ + readonly anhaenge: readonly { + readonly id: string; + readonly dateiname: string; + readonly medientyp: string; + readonly groesse: number; + }[]; }; export type DruckAbschnitt = { @@ -216,6 +224,9 @@ export async function laufZumDrucken( // Lesemodell fuer dieselben Zeilen waere eine Stelle mehr, die sie vergessen // kann. const alleKommentare = await kommentareDesLaufs(laufId, benutzer); + // Nur Metadaten: der Ausdruck verweist auf die Adresse, er traegt die Datei + // nicht in sich (#032). + const alleAnhaenge = await anhaengeDesLaufs(laufId); const ergebnisse = new Map(lauf.ergebnisse.map((ergebnis) => [ergebnis.vorlagenSchrittId, ergebnis])); let nummer = 0; @@ -264,6 +275,14 @@ export async function laufZumDrucken( ergebnis?.aktualisiertVon?.name ?? (zustand === SchrittZustand.OFFEN ? null : "System"), bearbeitetAm: zustand === SchrittZustand.OFFEN ? null : (ergebnis?.aktualisiertAm ?? null), kommentare: alleKommentare.filter((kommentar) => kommentar.schrittergebnisId === ergebnis?.id), + anhaenge: alleAnhaenge + .filter((anhang) => anhang.schrittergebnisId === ergebnis?.id) + .map((anhang) => ({ + id: anhang.id, + dateiname: anhang.dateiname, + medientyp: anhang.medientyp, + groesse: anhang.groesse, + })), }; }); diff --git a/src/server/services/anhaenge.ts b/src/server/services/anhaenge.ts new file mode 100644 index 0000000..b4f7ff0 --- /dev/null +++ b/src/server/services/anhaenge.ts @@ -0,0 +1,262 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { prisma } from "@/lib/db"; +import { + ENDUNG_JE_TYP, + anhaengePfad, + dateinameSaeubern, + groesseText, + istErlaubterMedientyp, + maxGroesseBytes, + maxJeLauf, + type Medientyp, +} from "@/lib/anhaenge"; +import { laufZumSchreibenHolen } from "@/server/services/laeufe"; +import { kundeZugriffPruefen, type HandelnderBenutzer } from "@/server/mandant"; + +/** + * Anhaenge und Fotos an Schritten (#032). + * + * Die Datei liegt im Dateisystem, die Metadaten in der Datenbank. Der Pfad + * entsteht **aus der Kennung**, nie aus dem Namen vom Browser — ein Dateiname + * von aussen darf keinen Pfad bestimmen. + * + * Der Archiv-Schreibschutz gilt auch hier: `laufZumSchreibenHolen` sperrt die + * Laufzeile, prueft die Mandantengrenze und ruft `laufMussVeraenderbarSein` — + * dieselbe eine Stelle wie bei Schrittergebnissen und Kommentaren. + */ + +export class AnhangFehler extends Error { + constructor(meldung: string) { + super(meldung); + this.name = "AnhangFehler"; + } +} + +/** Der Pfad einer Datei. Nach Kunde und Lauf gegliedert, damit ein Verzeichnis überschaubar bleibt. */ +function dateiPfad(anhang: { + readonly id: string; + readonly kundeId: string; + readonly laufId: string; + readonly medientyp: string; +}): string { + const endung = istErlaubterMedientyp(anhang.medientyp) ? ENDUNG_JE_TYP[anhang.medientyp] : "bin"; + return join(anhaengePfad(), anhang.kundeId, anhang.laufId, `${anhang.id}.${endung}`); +} + +export type AnhangEingabe = { + readonly laufId: string; + readonly vorlagenSchrittId: string; + readonly dateiname: string; + readonly medientyp: string; + readonly inhalt: Uint8Array; +}; + +/** + * Legt einen Anhang an einem Schritt ab. + * + * Reihenfolge mit Absicht: erst alles pruefen, dann die Datei schreiben, dann + * die Zeile anlegen. Bricht das Anlegen der Zeile ab, wird die Datei wieder + * entfernt — eine Datei ohne Zeile waere Muell, den niemand findet. Umgekehrt + * waere es schlimmer: eine Zeile ohne Datei ist ein Anhang, der beim Oeffnen + * fehlschlaegt. + */ +export async function anhangSpeichern( + eingabe: AnhangEingabe, + benutzer: HandelnderBenutzer, +): Promise<{ readonly id: string }> { + if (!istErlaubterMedientyp(eingabe.medientyp)) { + throw new AnhangFehler( + `Dateien vom Typ „${eingabe.medientyp || "unbekannt"}“ lassen sich nicht anhängen. ` + + "Erlaubt sind Bilder (JPEG, PNG, WebP, HEIC) und PDF.", + ); + } + if (eingabe.inhalt.byteLength === 0) { + throw new AnhangFehler("Die Datei ist leer."); + } + const grenze = maxGroesseBytes(); + if (eingabe.inhalt.byteLength > grenze) { + throw new AnhangFehler( + `Die Datei ist ${groesseText(eingabe.inhalt.byteLength)} groß. Erlaubt sind höchstens ` + + `${groesseText(grenze)} je Datei.`, + ); + } + + const vorbereitet = await prisma.$transaction(async (tx) => { + // Sperrt den Lauf, prueft Mandant **und** Schreibschutz – die eine Stelle. + const lauf = await laufZumSchreibenHolen(tx, eingabe.laufId, benutzer); + + const ergebnis = await tx.schrittergebnis.findUnique({ + where: { + laufId_vorlagenSchrittId: { + laufId: lauf.id, + vorlagenSchrittId: eingabe.vorlagenSchrittId, + }, + }, + select: { id: true }, + }); + if (!ergebnis) { + throw new AnhangFehler("Diesen Schritt gibt es in diesem Lauf nicht."); + } + + const vorhanden = await tx.anhang.count({ where: { laufId: lauf.id } }); + const hoechstens = maxJeLauf(); + if (vorhanden >= hoechstens) { + throw new AnhangFehler( + `An diesem Lauf hängen schon ${vorhanden} Dateien. Mehr als ${hoechstens} je Lauf ` + + "nimmt Laufzettel nicht — erst eine entfernen.", + ); + } + + return { laufId: lauf.id, kundeId: lauf.kundeId, schrittergebnisId: ergebnis.id }; + }); + + const pruefsumme = createHash("sha256").update(eingabe.inhalt).digest("hex"); + const id = `anh_${createHash("sha256") + .update(`${vorbereitet.schrittergebnisId}:${pruefsumme}:${Date.now()}:${Math.random()}`) + .digest("hex") + .slice(0, 24)}`; + const pfad = dateiPfad({ + id, + kundeId: vorbereitet.kundeId, + laufId: vorbereitet.laufId, + medientyp: eingabe.medientyp, + }); + + await mkdir(join(pfad, ".."), { recursive: true }); + await writeFile(pfad, eingabe.inhalt, { flag: "wx" }); + + try { + await prisma.anhang.create({ + data: { + id, + schrittergebnisId: vorbereitet.schrittergebnisId, + laufId: vorbereitet.laufId, + kundeId: vorbereitet.kundeId, + dateiname: dateinameSaeubern(eingabe.dateiname), + medientyp: eingabe.medientyp, + groesse: eingabe.inhalt.byteLength, + pruefsumme, + erstelltVonId: benutzer.id, + }, + }); + } catch (fehler) { + // Die Datei wieder weg: eine Datei ohne Zeile findet niemand mehr. + await rm(pfad, { force: true }); + throw fehler; + } + + return { id }; +} + +/** + * Den Inhalt eines Anhangs holen – mit Recht und Mandantengrenze. + * + * **Jeder** Abruf prueft beides, auch der des Bildes selbst. Eine Adresse, die + * ohne Pruefung ausliefert, waere die Luecke, die alles andere umgeht. + */ +export async function anhangLesen( + anhangId: string, + benutzer: HandelnderBenutzer, +): Promise<{ + readonly inhalt: Buffer; + readonly medientyp: string; + readonly dateiname: string; + readonly groesse: number; +}> { + const anhang = await prisma.anhang.findUnique({ where: { id: anhangId } }); + if (!anhang) throw new AnhangFehler("Diesen Anhang gibt es nicht (mehr)."); + await kundeZugriffPruefen(anhang.kundeId, benutzer); + + const pfad = dateiPfad(anhang); + let inhalt: Buffer; + try { + inhalt = await readFile(pfad); + } catch { + /* + * Die Zeile steht, die Datei fehlt. Das ist der Fall nach einer Sicherung, + * die nur die Datenbank mitgenommen hat — und deshalb sagt die Meldung das + * auch, statt „nicht gefunden“ zu behaupten. + */ + throw new AnhangFehler( + `Die Datei zu „${anhang.dateiname}“ fehlt im Dateisystem. Wurde eine Sicherung ` + + "eingespielt, die nur die Datenbank enthielt? Siehe docs/betrieb/sicherung.md.", + ); + } + return { + inhalt, + medientyp: anhang.medientyp, + dateiname: anhang.dateiname, + groesse: anhang.groesse, + }; +} + +/** + * Entfernt einen Anhang. + * + * Erst die Zeile, dann die Datei: bleibt die Datei liegen, ist sie unerreichbar + * und beim naechsten Aufraeumen zu finden. Bliebe umgekehrt die Zeile stehen, + * zeigte die Oberflaeche einen Anhang, der nicht mehr zu oeffnen ist. + */ +export async function anhangEntfernen(anhangId: string, benutzer: HandelnderBenutzer): Promise { + const anhang = await prisma.anhang.findUnique({ where: { id: anhangId } }); + if (!anhang) throw new AnhangFehler("Diesen Anhang gibt es nicht (mehr)."); + + await prisma.$transaction(async (tx) => { + // Sperrt, prueft Mandant und Schreibschutz – an einem archivierten Lauf + // kommt kein Anhang weg. + await laufZumSchreibenHolen(tx, anhang.laufId, benutzer); + await tx.anhang.delete({ where: { id: anhangId } }); + }); + + await rm(dateiPfad(anhang), { force: true }); +} + +/** Die Anhaenge eines Laufs, je Schrittergebnis. Nur Metadaten. */ +export async function anhaengeDesLaufs(laufId: string): Promise< + readonly { + readonly id: string; + readonly schrittergebnisId: string; + readonly dateiname: string; + readonly medientyp: string; + readonly groesse: number; + readonly erstelltAm: Date; + readonly erstelltVon: string | null; + }[] +> { + const zeilen = await prisma.anhang.findMany({ + where: { laufId }, + orderBy: { erstelltAm: "asc" }, + select: { + id: true, + schrittergebnisId: true, + dateiname: true, + medientyp: true, + groesse: true, + erstelltAm: true, + erstelltVon: { select: { name: true, email: true } }, + }, + }); + return zeilen.map((zeile) => ({ + id: zeile.id, + schrittergebnisId: zeile.schrittergebnisId, + dateiname: zeile.dateiname, + medientyp: zeile.medientyp, + groesse: zeile.groesse, + erstelltAm: zeile.erstelltAm, + erstelltVon: zeile.erstelltVon?.name ?? zeile.erstelltVon?.email ?? null, + })); +} + +/** Nur fuer Tests und Werkzeuge: der Pfad einer Zeile. */ +export function anhangPfad(anhang: { + readonly id: string; + readonly kundeId: string; + readonly laufId: string; + readonly medientyp: string; +}): string { + return dateiPfad(anhang); +} + +export type { Medientyp }; diff --git a/tests/anhaenge.test.ts b/tests/anhaenge.test.ts new file mode 100644 index 0000000..86ec43a --- /dev/null +++ b/tests/anhaenge.test.ts @@ -0,0 +1,486 @@ +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { Betriebsmodus, LaufStatus, VersionStatus } from "@/generated/prisma/enums"; +import { + dateinameSaeubern, + gewaehlteDatei, + groesseText, + istBild, + istErlaubterMedientyp, +} from "@/lib/anhaenge"; +import { + AnhangFehler, + anhaengeDesLaufs, + anhangEntfernen, + anhangLesen, + anhangPfad, + anhangSpeichern, +} from "@/server/services/anhaenge"; +import { MandantFehler } from "@/server/mandant"; +import { LaufFehler } from "@/server/services/laeufe"; +import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; + +/** + * Anhaenge und Fotos an Schritten (#032). + * + * Vier Zusagen tragen das Ganze: der Archiv-Schreibschutz gilt auch hier, ein + * fremder Kunde kommt nicht heran, die Grenzen greifen mit einer Begruendung, + * und eine Datei ohne Zeile bleibt nicht liegen. + * + * Die Dateien landen in einem Wegwerf-Verzeichnis — `ANHAENGE_PFAD` wird fuer + * diese Datei umgebogen und danach wieder weggeraeumt. + */ + +const VORHER = process.env["ANHAENGE_PFAD"]; +let verzeichnis = ""; + +beforeAll(async () => { + verzeichnis = await mkdtemp(join(tmpdir(), "laufzettel-anhaenge-")); + process.env["ANHAENGE_PFAD"] = verzeichnis; +}); + +afterAll(async () => { + if (VORHER === undefined) delete process.env["ANHAENGE_PFAD"]; + else process.env["ANHAENGE_PFAD"] = VORHER; + if (verzeichnis) await rm(verzeichnis, { recursive: true, force: true }); +}); + +describe("Formen und Grenzen", () => { + it("erlaubt Bilder und PDF, aber kein SVG", () => { + // SVG ist ein Dokument mit Skripten, das ein Betrachter ausführt. Ein + // Anhang, den jemand öffnet, darf kein Angriffsweg sein. + expect(istErlaubterMedientyp("image/jpeg")).toBe(true); + expect(istErlaubterMedientyp("application/pdf")).toBe(true); + expect(istErlaubterMedientyp("image/svg+xml")).toBe(false); + expect(istErlaubterMedientyp("text/html")).toBe(false); + }); + + it("zeigt nur Bilder als Miniatur", () => { + expect(istBild("image/png")).toBe(true); + expect(istBild("application/pdf")).toBe(false); + }); + + it("macht aus einem Pfad einen Dateinamen und wirft Steuerzeichen weg", () => { + // Der Name wird nie zum Pfad – trotzdem landet er in einer Kopfzeile. + expect(dateinameSaeubern("../../etc/passwd")).toBe("passwd"); + expect(dateinameSaeubern("C:\\Users\\x\\foto.jpg")).toBe("foto.jpg"); + expect(dateinameSaeubern('fo"to\u0000.jpg')).toBe("foto.jpg"); + expect(dateinameSaeubern(" ")).toBe("anhang"); + expect(dateinameSaeubern("x".repeat(300))).toHaveLength(200); + }); + + it("schreibt Größen deutsch", () => { + expect(groesseText(512)).toBe("512 B"); + expect(groesseText(2048)).toBe("2 KB"); + expect(groesseText(3 * 1024 * 1024 + 100_000)).toBe("3,1 MB"); + }); + + it("nimmt aus zwei Feldern „datei“ das nicht leere", () => { + /* + * Der Schritt hat zwei Felder „datei“ – „Foto“ und „Datei“ – in einem + * Formular, also kommt immer auch das leere mit. `FormData.get` lieferte + * das erste, und wer „Datei“ wählte, bekam „Keine Datei gewählt“. Gefunden + * in der Klickprobe am gebauten Stand; kein Dienst-Test schickt zwei + * Felder. + */ + const leer = new File([], ""); + const echt = new File([new Uint8Array([1, 2, 3])], "foto.png", { type: "image/png" }); + + expect(gewaehlteDatei([leer, echt])?.name).toBe("foto.png"); + expect(gewaehlteDatei([echt, leer])?.name).toBe("foto.png"); + expect(gewaehlteDatei([leer, leer])).toBeUndefined(); + expect(gewaehlteDatei(["nicht einmal eine Datei", null])).toBeUndefined(); + }); +}); + +async function aufbau() { + const kunde = await testPrisma.kunde.create({ + data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true }, + }); + await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } }); + const admin = await testPrisma.benutzer.create({ + data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" }, + }); + + const vorlage = await testPrisma.vorlage.create({ data: { name: "PC Neuinstallation" } }); + const version = await testPrisma.vorlagenVersion.create({ + data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF }, + }); + const abschnitt = await testPrisma.vorlagenAbschnitt.create({ + data: { versionId: version.id, titel: "Vorbereitung", position: 0 }, + }); + const schritt = await testPrisma.vorlagenSchritt.create({ + data: { abschnittId: abschnitt.id, titel: "Typenschild fotografieren", position: 0 }, + }); + await testPrisma.vorlagenVersion.update({ + where: { id: version.id }, + data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() }, + }); + + const lauf = await testPrisma.lauf.create({ + data: { vorlagenVersionId: version.id, titel: "Ein Lauf", kundeId: kunde.id }, + }); + const ergebnis = await testPrisma.schrittergebnis.create({ + data: { laufId: lauf.id, vorlagenSchrittId: schritt.id }, + }); + + return { + kunde, + admin: { id: admin.id, rolle: admin.rolle }, + laufId: lauf.id, + schrittId: schritt.id, + ergebnisId: ergebnis.id, + }; +} + +/** Ein winziges, gültiges PNG. */ +const PNG = Buffer.from( + "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489" + + "0000000a49444154789c6300010000050001" + + "0d0a2db40000000049454e44ae426082", + "hex", +); + +describe.runIf(datenbankVerfuegbar)("Anhänge", () => { + beforeEach(async () => { + await datenbankLeeren(); + }); + + it("legt eine Datei ab und schreibt die Metadaten", async () => { + const { admin, laufId, schrittId, ergebnisId, kunde } = await aufbau(); + const { id } = await anhangSpeichern( + { + laufId, + vorlagenSchrittId: schrittId, + dateiname: "typenschild.png", + medientyp: "image/png", + inhalt: new Uint8Array(PNG), + }, + admin, + ); + + const zeile = await testPrisma.anhang.findUniqueOrThrow({ where: { id } }); + expect(zeile).toMatchObject({ + schrittergebnisId: ergebnisId, + laufId, + kundeId: kunde.id, + dateiname: "typenschild.png", + medientyp: "image/png", + groesse: PNG.byteLength, + erstelltVonId: admin.id, + }); + // SHA-256 als Hex – eine CHECK-Bedingung hält die Form fest. + expect(zeile.pruefsumme).toMatch(/^[0-9a-f]{64}$/); + + // Die Datei liegt wirklich da, und zwar unter einem Pfad aus der Kennung. + const pfad = anhangPfad(zeile); + expect(pfad).toContain(kunde.id); + expect(pfad).toContain(laufId); + expect(pfad).not.toContain("typenschild"); + expect((await stat(pfad)).size).toBe(PNG.byteLength); + expect(await readFile(pfad)).toEqual(PNG); + }); + + it("liest den Inhalt wieder zurück", async () => { + const { admin, laufId, schrittId } = await aufbau(); + const { id } = await anhangSpeichern( + { + laufId, + vorlagenSchrittId: schrittId, + dateiname: "a.png", + medientyp: "image/png", + inhalt: new Uint8Array(PNG), + }, + admin, + ); + + const datei = await anhangLesen(id, admin); + expect(datei.inhalt).toEqual(PNG); + expect(datei.medientyp).toBe("image/png"); + expect(datei.dateiname).toBe("a.png"); + }); + + it("weist einen fremden Medientyp mit Begründung ab", async () => { + const { admin, laufId, schrittId } = await aufbau(); + await expect( + anhangSpeichern( + { + laufId, + vorlagenSchrittId: schrittId, + dateiname: "boese.svg", + medientyp: "image/svg+xml", + inhalt: new Uint8Array(PNG), + }, + admin, + ), + ).rejects.toThrow(/lassen sich nicht anhängen/); + }); + + it("weist eine zu große Datei mit Größen in der Meldung ab", async () => { + const vorher = process.env["ANHANG_MAX_MB"]; + process.env["ANHANG_MAX_MB"] = "1"; + try { + const { admin, laufId, schrittId } = await aufbau(); + await expect( + anhangSpeichern( + { + laufId, + vorlagenSchrittId: schrittId, + dateiname: "gross.png", + medientyp: "image/png", + inhalt: new Uint8Array(2 * 1024 * 1024), + }, + admin, + ), + ).rejects.toThrow(/2,0 MB groß.*höchstens 1,0 MB/s); + } finally { + if (vorher === undefined) delete process.env["ANHANG_MAX_MB"]; + else process.env["ANHANG_MAX_MB"] = vorher; + } + }); + + it("weist eine leere Datei ab", async () => { + const { admin, laufId, schrittId } = await aufbau(); + await expect( + anhangSpeichern( + { + laufId, + vorlagenSchrittId: schrittId, + dateiname: "leer.png", + medientyp: "image/png", + inhalt: new Uint8Array(0), + }, + admin, + ), + ).rejects.toThrow(/leer/); + }); + + it("begrenzt die Anzahl je Lauf", async () => { + const vorher = process.env["ANHAENGE_JE_LAUF"]; + process.env["ANHAENGE_JE_LAUF"] = "2"; + try { + const { admin, laufId, schrittId } = await aufbau(); + const eingabe = { + laufId, + vorlagenSchrittId: schrittId, + dateiname: "a.png", + medientyp: "image/png", + inhalt: new Uint8Array(PNG), + }; + await anhangSpeichern(eingabe, admin); + await anhangSpeichern(eingabe, admin); + await expect(anhangSpeichern(eingabe, admin)).rejects.toThrow(/Mehr als 2 je Lauf/); + } finally { + if (vorher === undefined) delete process.env["ANHAENGE_JE_LAUF"]; + else process.env["ANHAENGE_JE_LAUF"] = vorher; + } + }); + + it("weist an einem archivierten Lauf ab – Hochladen und Entfernen", async () => { + /* + * Der Archiv-Schreibschutz sitzt an genau einer Stelle + * (`laufZumSchreibenHolen` → `laufMussVeraenderbarSein`). Dass er auch hier + * greift, ist das Kriterium aus dem Issue. + */ + const { admin, laufId, schrittId } = await aufbau(); + const { id } = await anhangSpeichern( + { + laufId, + vorlagenSchrittId: schrittId, + dateiname: "a.png", + medientyp: "image/png", + inhalt: new Uint8Array(PNG), + }, + admin, + ); + // Eine CHECK-Bedingung verlangt zu einem archivierten Lauf auch einen + // Abschlusszeitpunkt: archiviert wird nur, was fertig ist. + await testPrisma.lauf.update({ + where: { id: laufId }, + data: { + status: LaufStatus.ARCHIVIERT, + gestartetAm: new Date(), + abgeschlossenAm: new Date(), + archiviertAm: new Date(), + }, + }); + + await expect( + anhangSpeichern( + { + laufId, + vorlagenSchrittId: schrittId, + dateiname: "b.png", + medientyp: "image/png", + inhalt: new Uint8Array(PNG), + }, + admin, + ), + ).rejects.toThrow(LaufFehler); + await expect(anhangEntfernen(id, admin)).rejects.toThrow(/schreibgeschützt/); + + // Lesen bleibt erlaubt: ein Foto vom Typenschild ist Teil des Belegs. + expect((await anhangLesen(id, admin)).inhalt).toEqual(PNG); + }); + + it("weist einen Schritt ab, der nicht zu diesem Lauf gehört", async () => { + const { admin, laufId } = await aufbau(); + await expect( + anhangSpeichern( + { + laufId, + vorlagenSchrittId: "gibt-es-nicht", + dateiname: "a.png", + medientyp: "image/png", + inhalt: new Uint8Array(PNG), + }, + admin, + ), + ).rejects.toThrow(/Schritt gibt es in diesem Lauf nicht/); + }); + + it("entfernt Zeile und Datei zusammen", async () => { + const { admin, laufId, schrittId } = await aufbau(); + const { id } = await anhangSpeichern( + { + laufId, + vorlagenSchrittId: schrittId, + dateiname: "a.png", + medientyp: "image/png", + inhalt: new Uint8Array(PNG), + }, + admin, + ); + const zeile = await testPrisma.anhang.findUniqueOrThrow({ where: { id } }); + const pfad = anhangPfad(zeile); + + await anhangEntfernen(id, admin); + expect(await testPrisma.anhang.findUnique({ where: { id } })).toBeNull(); + await expect(stat(pfad)).rejects.toThrow(); + }); + + it("sagt, wenn die Datei fehlt – und nennt die Sicherung als Grund", async () => { + // Der Fall nach einer Sicherung, die nur die Datenbank mitgenommen hat. + const { admin, laufId, schrittId } = await aufbau(); + const { id } = await anhangSpeichern( + { + laufId, + vorlagenSchrittId: schrittId, + dateiname: "a.png", + medientyp: "image/png", + inhalt: new Uint8Array(PNG), + }, + admin, + ); + const zeile = await testPrisma.anhang.findUniqueOrThrow({ where: { id } }); + await rm(anhangPfad(zeile)); + + await expect(anhangLesen(id, admin)).rejects.toThrow(/sicherung\.md/); + }); + + it("zählt die Anhänge eines Laufs mit Urheber", async () => { + const { admin, laufId, schrittId, ergebnisId } = await aufbau(); + await anhangSpeichern( + { + laufId, + vorlagenSchrittId: schrittId, + dateiname: "eins.png", + medientyp: "image/png", + inhalt: new Uint8Array(PNG), + }, + admin, + ); + + const liste = await anhaengeDesLaufs(laufId); + expect(liste).toHaveLength(1); + expect(liste[0]).toMatchObject({ + schrittergebnisId: ergebnisId, + dateiname: "eins.png", + erstelltVon: "Admin", + }); + }); + + it("hält die Mandantengrenze in der Datenbank, nicht nur im Dienst", async () => { + const { laufId, schrittId, ergebnisId } = await aufbau(); + const fremd = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FREMD" } }); + expect(schrittId).toBeTruthy(); + + // Ein Anhang mit fremdem Kunden, an der Service-Schicht vorbei: der + // zusammengesetzte Fremdschlüssel weist das ab. + await expect( + testPrisma.anhang.create({ + data: { + schrittergebnisId: ergebnisId, + laufId, + kundeId: fremd.id, + dateiname: "x.png", + medientyp: "image/png", + groesse: 10, + pruefsumme: "a".repeat(64), + }, + }), + ).rejects.toThrow(/Anhang_lauf_gleicher_kunde/); + }); + + it("weist eine unmögliche Größe und eine falsche Prüfsumme ab", async () => { + const { laufId, ergebnisId, kunde } = await aufbau(); + const basis = { + schrittergebnisId: ergebnisId, + laufId, + kundeId: kunde.id, + dateiname: "x.png", + medientyp: "image/png", + }; + await expect( + testPrisma.anhang.create({ data: { ...basis, groesse: 0, pruefsumme: "a".repeat(64) } }), + ).rejects.toThrow(/Anhang_groesse_positiv/); + await expect( + testPrisma.anhang.create({ data: { ...basis, groesse: 10, pruefsumme: "keine pruefsumme" } }), + ).rejects.toThrow(/Anhang_pruefsumme_form/); + }); + + it("verweigert den Abruf für einen auf andere Kunden beschränkten Benutzer", async () => { + const vorher = process.env["OIDC_CUSTOMER_GROUP_PREFIX"]; + process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "kunde-"; + try { + const { admin, laufId, schrittId } = await aufbau(); + await testPrisma.einstellung.update({ + where: { id: "global" }, + data: { modus: Betriebsmodus.DIENSTLEISTUNG }, + }); + const { id } = await anhangSpeichern( + { + laufId, + vorlagenSchrittId: schrittId, + dateiname: "a.png", + medientyp: "image/png", + inhalt: new Uint8Array(PNG), + }, + admin, + ); + + const fremd = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FREMD" } }); + const techniker = await testPrisma.benutzer.create({ + data: { email: "tech@firma.example", name: "Techniker", rolle: "TECHNIKER" }, + }); + await testPrisma.benutzerKunde.create({ + data: { benutzerId: techniker.id, kundeId: fremd.id }, + }); + + await expect(anhangLesen(id, { id: techniker.id, rolle: techniker.rolle })).rejects.toThrow( + MandantFehler, + ); + } finally { + if (vorher === undefined) delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"]; + else process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = vorher; + } + }); + + it("wirft bei einem Anhang, den es nicht gibt, einen eigenen Fehler", async () => { + const { admin } = await aufbau(); + await expect(anhangLesen("gibt-es-nicht", admin)).rejects.toThrow(AnhangFehler); + await expect(anhangEntfernen("gibt-es-nicht", admin)).rejects.toThrow(AnhangFehler); + }); +}); diff --git a/tests/betriebsstand.test.ts b/tests/betriebsstand.test.ts index 9b1bb1e..38a7428 100644 --- a/tests/betriebsstand.test.ts +++ b/tests/betriebsstand.test.ts @@ -49,6 +49,32 @@ describe("Betriebsstand: docker-compose.yml", () => { expect(probe).toEqual(["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/gesundheit"]); }); + it("hat ein Volume für die Anhänge – sonst sind sie beim nächsten Neuaufbau weg", () => { + /* + * Die Dateien der Anhaenge liegen im Dateisystem, in der Datenbank stehen + * nur die Metadaten (#032). Ohne Volume lebten sie im Container: ein + * `docker compose up --build` waere Datenverlust, und die Zeilen zeigten auf + * Dateien, die es nicht mehr gibt. + */ + const web = stand.services["web"] as unknown as { + volumes?: string[]; + environment?: Record; + }; + expect(web.volumes).toContain("anhaenge:/daten/anhaenge"); + expect(web.environment?.["ANHAENGE_PFAD"]).toBe("/daten/anhaenge"); + expect(Object.keys((stand as unknown as { volumes: Record }).volumes)).toContain( + "anhaenge", + ); + }); + + it("nennt das Anhang-Volume in der Sicherungsanleitung", () => { + // Eine Sicherung, die nur `pg_dump` macht, verliert ab #032 Daten. Dass die + // Anleitung das sagt, ist Teil der Zusage – nicht nur das Volume selbst. + const sicherung = readFileSync("docs/betrieb/sicherung.md", "utf8"); + expect(sicherung).toContain("anhaenge"); + expect(sicherung).toMatch(/ANHAENGE_PFAD/); + }); + it("bricht ohne Geheimnisse ab, statt mit Standardwerten zu starten", () => { const roh = readFileSync("docker-compose.yml", "utf8"); expect(roh).toContain("${APP_SECRET:?"); diff --git a/tests/datenbankregeln.test.ts b/tests/datenbankregeln.test.ts index 58969bf..29d9c76 100644 --- a/tests/datenbankregeln.test.ts +++ b/tests/datenbankregeln.test.ts @@ -23,10 +23,16 @@ async function namen(abfrage: string): Promise { describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => { it("hat die zusammengesetzten Fremdschlüssel der Mandantengrenze", async () => { - // Ohne sie kann ein Lauf ein Gerät eines fremden Kunden ziehen. + // Ohne sie kann ein Lauf ein Gerät eines fremden Kunden ziehen — und ein + // Anhang an einem Schritt hängen, der zu einem anderen Lauf gehört. expect( - await namen(`SELECT conname AS name FROM pg_constraint WHERE conname LIKE '%gleicher_kunde'`), + await namen( + `SELECT conname AS name FROM pg_constraint + WHERE conname LIKE '%gleicher_kunde' OR conname LIKE '%gleicher_lauf'`, + ), ).toEqual([ + "Anhang_ergebnis_gleicher_lauf", + "Anhang_lauf_gleicher_kunde", "Geraet_besitzer_gleicher_kunde", "Lauf_abteilung_gleicher_kunde", "Lauf_geraet_gleicher_kunde", @@ -80,6 +86,8 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => { ); // Namentlich aufgezählt, damit auch das Verschwinden einer einzelnen auffällt. expect(vorhanden).toEqual([ + "Anhang_groesse_positiv", + "Anhang_pruefsumme_form", "Einstellung_einzelne_zeile", "Geraet_registriert_braucht_namen", "Geraet_tagcode_form", diff --git a/tests/e2e/anhaenge.spec.ts b/tests/e2e/anhaenge.spec.ts new file mode 100644 index 0000000..e99e82d --- /dev/null +++ b/tests/e2e/anhaenge.spec.ts @@ -0,0 +1,151 @@ +import { expect, test } from "@playwright/test"; +import "dotenv/config"; +import { createHash } from "node:crypto"; +import { stat } from "node:fs/promises"; +import { prisma } from "@/lib/db"; +import { anhangPfad } from "@/server/services/anhaenge"; +import { SEED_TAG, SEED_VORLAGE, anmelden, kundeWaehlen, variablenFuellen, vorlageWaehlen } from "./helfer"; + +/** + * Fotos und Dateien an einem Schritt, durch die Oberflaeche (#032). + * + * Dieser Ablauf gibt es, weil der Dienst-Test ihn nicht ersetzen konnte. Der + * Schritt hat **zwei** Felder `datei` in einem Formular — „Foto“ und „Datei“ —, + * und `FormData.get` lieferte das erste, also das leere: wer „Datei“ waehlte, + * bekam „Keine Datei gewählt“, waehrend „Foto“ ging. Neunzehn gruene + * Dienst-Tests sahen davon nichts, weil keiner zwei Felder schickt. Gefunden hat + * es die Klickprobe am gebauten Stand; festgehalten wird es hier. + * + * Geprueft wird deshalb bewusst **ueber den zweiten Knopf** und danach, ob es + * wirklich steht: Neuladen, Zeile in der Datenbank, Datei auf der Platte, und + * die Pruefsumme der ausgelieferten Bytes gegen die gespeicherte. + */ + +const STEMPEL = Date.now(); + +/* Ein echtes 2x2-PNG. Eine leere Datei wuerde der Dienst zu Recht abweisen. */ +const PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAACZgbYnAAAAEUlEQVR4nGP8z8DAwMDEwAAAEAAHAAo7/UkAAAAASUVORK5CYII=", + "base64", +); + +/* Der zweite Ablauf arbeitet am Lauf des ersten weiter – also der Reihe nach. */ +test.describe.serial("Anhänge an einem Schritt", () => { + let laufId = ""; + + test("über „Datei“ hochladen, neu laden, entfernen", async ({ page }) => { + test.slow(); + await anmelden(page); + + await page.goto("/laeufe/starten"); + await vorlageWaehlen(page, SEED_VORLAGE); + const geraet = await prisma.geraet.findUniqueOrThrow({ + where: { tagCode: SEED_TAG }, + select: { id: true, kundeId: true }, + }); + await kundeWaehlen(page, geraet.kundeId); + await page.selectOption('select[name="geraetId"]', geraet.id); + await page.locator("details summary", { hasText: "Titel, Ticket" }).click(); + await page.getByLabel("Titel", { exact: true }).fill(`ZZ E2E Anhang ${STEMPEL}`); + await variablenFuellen(page); + await page.getByRole("button", { name: "Lauf anlegen" }).click(); + await page.waitForURL(/\/laeufe\/(?!starten)[^/]+$/, { timeout: 30_000 }); + laufId = new URL(page.url()).pathname.split("/").pop() ?? ""; + + /* + * Beginnen, wie der Techniker es taete. Anhaengen ginge auch am offenen + * Lauf — `lesend` haengt am Archiv und am Recht, nicht am Status —, aber + * genau so wird die Oberflaeche benutzt. + */ + await page.getByRole("button", { name: "Lauf beginnen" }).click(); + await expect(page.getByRole("button", { name: "Lauf abschließen" })).toBeVisible(); + + for (const kopf of await page.locator("button[aria-expanded]").all()) { + if ((await kopf.getAttribute("aria-expanded")) === "false") await kopf.click(); + } + + /* + * Feld 0 ist „Foto“, Feld 1 ist „Datei“ – beide heissen `datei` und stehen + * im selben Formular. Genau der zweite Weg war kaputt, also geht der Ablauf + * ueber ihn. + */ + const felder = page.locator('input[name="datei"]'); + expect(await felder.count()).toBeGreaterThan(1); + await felder.nth(1).setInputFiles({ name: "serverraum.png", mimeType: "image/png", buffer: PNG }); + + const miniatur = page.locator('img[src^="/api/anhaenge/"]').first(); + await expect(miniatur).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText("serverraum.png").first()).toBeVisible(); + + // Neu laden: steht es wirklich, oder nur im Zustand der Seite? + await page.reload(); + await expect(page.locator('img[src^="/api/anhaenge/"]').first()).toBeVisible({ timeout: 30_000 }); + + const zeile = await prisma.anhang.findFirstOrThrow({ + where: { laufId }, + select: { + id: true, + kundeId: true, + laufId: true, + dateiname: true, + medientyp: true, + groesse: true, + pruefsumme: true, + }, + }); + expect(zeile).toMatchObject({ + dateiname: "serverraum.png", + medientyp: "image/png", + groesse: PNG.byteLength, + pruefsumme: createHash("sha256").update(PNG).digest("hex"), + }); + + // Und auf der Platte, unter dem Pfad aus der Kennung. + const pfad = anhangPfad(zeile); + expect((await stat(pfad)).size).toBe(PNG.byteLength); + + // Die Auslieferung prueft Recht und Mandant selbst – und liefert die Bytes, + // die hereingingen. + const antwort = await page.request.get(`/api/anhaenge/${zeile.id}`); + expect(antwort.status()).toBe(200); + expect(antwort.headers()["content-type"]).toBe("image/png"); + expect(antwort.headers()["x-content-type-options"]).toBe("nosniff"); + expect( + createHash("sha256") + .update(await antwort.body()) + .digest("hex"), + ).toBe(zeile.pruefsumme); + + // Entfernen nimmt Zeile **und** Datei. + await page.getByRole("button", { name: "serverraum.png entfernen" }).click(); + await expect(page.locator('img[src^="/api/anhaenge/"]')).toHaveCount(0, { timeout: 30_000 }); + expect(await prisma.anhang.count({ where: { laufId } })).toBe(0); + await expect(stat(pfad)).rejects.toThrow(); + }); + + test("weist ein SVG ab, auch wenn es am Dateiwähler vorbei kommt", async ({ page }) => { + test.slow(); + await anmelden(page); + expect(laufId).not.toBe(""); + await page.goto(`/laeufe/${laufId}`); + for (const kopf of await page.locator("button[aria-expanded]").all()) { + if ((await kopf.getAttribute("aria-expanded")) === "false") await kopf.click(); + } + await page + .locator('input[name="datei"]') + .nth(1) + .setInputFiles({ + name: "boeses.svg", + mimeType: "image/svg+xml", + buffer: Buffer.from(''), + }); + + /* + * `accept` haelt im Dateiwaehler nur die Finger ab. Dass ein SVG nicht + * hereinkommt, muss der Server sagen – ein SVG ist ein Dokument mit + * Skripten, das ein Betrachter ausfuehrt. + */ + await expect(page.getByText(/lassen sich nicht anhängen/)).toBeVisible({ timeout: 30_000 }); + expect(await prisma.anhang.count({ where: { laufId } })).toBe(0); + }); +});