feat(anhaenge): Fotos und Dateien an einem Schritt (#52)
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, Groesse und Pruefsumme. Der Pfad entsteht aus der Kennung, nie aus dem Dateinamen vom Browser. Jeder Abruf prueft Recht und Mandantengrenze — auch der des Bildes selbst: der Route Handler ruft rechtErforderlich() selbst, weil die Huelle unter (app) Route Handler nicht abdeckt. Hochgeladen wird ueber eine Server Action statt ueber einen zweiten Endpunkt; der Archiv-Schreibschutz haengt an derselben einen Stelle wie alles andere am Lauf. Kein SVG: ein SVG ist ein Dokument, das Skripte ausfuehrt. In der Datenbank halten zwei CHECK-Bedingungen und zwei zusammengesetzte Fremdschluessel fest, was auch gilt, wenn jemand an der Service-Schicht vorbei schreibt — ein Anhang kann strukturell nicht an einem Schritt eines anderen Laufs oder bei einem fremden Kunden haengen. docs/betrieb/sicherung.md ist neu geschrieben: dort stand "Kein Upload-Verzeichnis, keine erzeugten Dateien", und eine Sicherung nach der alten Anleitung haette ab hier still Daten verloren. Sicherung und Ruecksicherung sind einmal wirklich gefahren, mit Pruefsummenvergleich. Gefunden in der Klickprobe am gebauten Stand und nur dort: zwei Felder "datei" in einem Formular, und FormData.get nahm das leere. Wer "Datei" waehlte, bekam "Keine Datei gewaehlt", waehrend "Foto" ging. Die Regel liegt jetzt als reine Funktion in src/lib, und tests/e2e/anhaenge.spec.ts laedt bewusst ueber den zweiten Knopf hoch. Closes #52 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
855164066c
commit
3cbd5026f8
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+123
-23
@@ -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-<Datum>.dump` und
|
||||
`laufzettel-anhaenge-<Datum>.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. |
|
||||
|
||||
+89
-2
@@ -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.
|
||||
|
||||
+56
-18
@@ -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 `<img>` 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.** `<ANHAENGE_PFAD>/<kundeId>/<laufId>/<id>.<endung>`.
|
||||
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.
|
||||
|
||||
@@ -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** |
|
||||
|
||||
@@ -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}$');
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
</ul>
|
||||
) : 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) ? (
|
||||
<div className="mt-3">
|
||||
<SchrittAnhaenge
|
||||
laufId={laufId}
|
||||
vorlagenSchrittId={schritt.schrittId}
|
||||
anhaenge={anhaenge}
|
||||
lesend={lesend || !schritt.ergebnisId}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{lesend || !schritt.ergebnisId ? null : (
|
||||
<details className="mt-2">
|
||||
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 text-xs text-nebentext hover:text-text md:min-h-9">
|
||||
@@ -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)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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<Response> {
|
||||
let benutzer: { id: string; rolle: Parameters<typeof anhangLesen>[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;
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
{bilder.length > 0 ? (
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{bilder.map((anhang) => (
|
||||
<li key={anhang.id} className="flex flex-col items-start gap-0.5">
|
||||
{/* Ohne next/image: der Optimierer holte die Datei erneut und ohne
|
||||
Rechteprüfung. */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`/api/anhaenge/${anhang.id}`}
|
||||
alt={anhang.dateiname}
|
||||
className="h-24 w-auto rounded border border-linie object-contain"
|
||||
/>
|
||||
<span className="max-w-32 truncate text-[10px] text-nebentext">{anhang.dateiname}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{dateien.length > 0 ? (
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{dateien.map((anhang) => (
|
||||
<li key={anhang.id} className="text-xs text-nebentext">
|
||||
Anhang: {anhang.dateiname} ({groesseText(anhang.groesse)})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Schritt({ schritt }: { schritt: DruckSchritt }) {
|
||||
const Symbol = ZUSTAND_SYMBOL[schritt.zustand];
|
||||
return (
|
||||
@@ -204,6 +250,7 @@ function Schritt({ schritt }: { schritt: DruckSchritt }) {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Anhaenge anhaenge={schritt.anhaenge} />
|
||||
<Kommentare kommentare={schritt.kommentare} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<AktionsErgebnis, FormData>(
|
||||
anhangHochladenAktion,
|
||||
{},
|
||||
);
|
||||
const [entfernen, entfernenAktion] = useActionState<AktionsErgebnis, FormData>(anhangEntfernenAktion, {});
|
||||
const formular = useRef<HTMLFormElement>(null);
|
||||
const [zuGross, setzeZuGross] = useState<string | null>(null);
|
||||
|
||||
function gewaehlt(ereignis: React.ChangeEvent<HTMLInputElement>): 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 (
|
||||
<div className="flex flex-col gap-2">
|
||||
{anhaenge.length > 0 ? (
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{anhaenge.map((anhang) => (
|
||||
<li key={anhang.id} className="flex flex-col gap-1">
|
||||
<a
|
||||
href={`/api/anhaenge/${anhang.id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={`${anhang.dateiname} · ${groesseText(anhang.groesse)}${
|
||||
anhang.erstelltVon ? ` · ${anhang.erstelltVon}` : ""
|
||||
}`}
|
||||
className="flex items-center gap-2 rounded-karte border border-linie bg-flaeche-hoch p-1.5"
|
||||
>
|
||||
{istBild(anhang.medientyp) ? (
|
||||
// Das Bild selbst als Miniatur – ohne next/image, weil der
|
||||
// Optimierer die Datei erneut und ohne Rechteprüfung holen
|
||||
// würde.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`/api/anhaenge/${anhang.id}`}
|
||||
alt={anhang.dateiname}
|
||||
className="size-14 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex size-14 items-center justify-center rounded bg-flaeche">
|
||||
<FileText className="size-6 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
<span className="flex min-w-0 max-w-40 flex-col text-xs">
|
||||
<span className="truncate">{anhang.dateiname}</span>
|
||||
<span className="text-nebentext">{groesseText(anhang.groesse)}</span>
|
||||
</span>
|
||||
</a>
|
||||
{lesend ? null : (
|
||||
<form action={entfernenAktion}>
|
||||
<input type="hidden" name="anhangId" value={anhang.id} />
|
||||
<input type="hidden" name="laufId" value={laufId} />
|
||||
<button
|
||||
type="submit"
|
||||
aria-label={`${anhang.dateiname} entfernen`}
|
||||
className="flex min-h-11 items-center gap-1 text-xs text-nebentext hover:text-fehler md:min-h-8"
|
||||
>
|
||||
<X className="size-3.5" strokeWidth={2} aria-hidden="true" />
|
||||
entfernen
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{lesend ? null : (
|
||||
<form ref={formular} action={hochladenAktion} className="flex flex-wrap items-center gap-2">
|
||||
<input type="hidden" name="laufId" value={laufId} />
|
||||
<input type="hidden" name="vorlagenSchrittId" value={vorlagenSchrittId} />
|
||||
|
||||
<label className="flex min-h-11 cursor-pointer items-center gap-1.5 rounded-pille border border-linie px-3 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-9">
|
||||
<Camera className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||
Foto
|
||||
<input
|
||||
type="file"
|
||||
name="datei"
|
||||
accept={ERLAUBTE_MEDIENTYPEN.join(",")}
|
||||
capture="environment"
|
||||
onChange={gewaehlt}
|
||||
className="sr-only"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex min-h-11 cursor-pointer items-center gap-1.5 rounded-pille border border-linie px-3 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-9">
|
||||
<Paperclip className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||
Datei
|
||||
<input
|
||||
type="file"
|
||||
name="datei"
|
||||
accept={ERLAUBTE_MEDIENTYPEN.join(",")}
|
||||
onChange={gewaehlt}
|
||||
className="sr-only"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{laedt ? <span className="text-sm text-nebentext">wird hochgeladen …</span> : null}
|
||||
</form>
|
||||
)}
|
||||
|
||||
{meldung ? (
|
||||
<p role="alert" className="max-w-prose text-sm text-fehler">
|
||||
{meldung}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Record<Medientyp, string>> = {
|
||||
"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);
|
||||
}
|
||||
@@ -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<AktionsErgebnis> {
|
||||
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<AktionsErgebnis> {
|
||||
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.");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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 };
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, string>;
|
||||
};
|
||||
expect(web.volumes).toContain("anhaenge:/daten/anhaenge");
|
||||
expect(web.environment?.["ANHAENGE_PFAD"]).toBe("/daten/anhaenge");
|
||||
expect(Object.keys((stand as unknown as { volumes: Record<string, unknown> }).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:?");
|
||||
|
||||
@@ -23,10 +23,16 @@ async function namen(abfrage: string): Promise<string[]> {
|
||||
|
||||
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",
|
||||
|
||||
@@ -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('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'),
|
||||
});
|
||||
|
||||
/*
|
||||
* `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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user