diff --git a/.env.example b/.env.example index fd81b84..acfdb31 100644 --- a/.env.example +++ b/.env.example @@ -113,6 +113,26 @@ SEED_ORG_KUERZEL=EIGEN # Migrationen fahren, und fuer Testlaeufe. ZEITPLAN_AKTIV=true +# ---- PDF-Uebergabeprotokoll (#041) ----------------------------------------- +# Das Protokoll eines Laufs als PDF entsteht, indem der Server seine **eigene** +# Druckansicht mit Chromium laedt. Dafuer muss Chromium da sein – und das ist er +# absichtlich nicht ueberall: im Bild kostet er 1,13 GB, mehr als die dreifache +# Anwendung. Ohne ihn zeigt die Oberflaeche den Knopf gar nicht erst, und die +# Druckansicht bleibt der Weg. Alles dazu in docs/betrieb/pdf.md. +# +# Leer lassen heisst „suche selbst“: /usr/bin/chromium-browser (Alpine), +# /usr/bin/chromium, /usr/bin/google-chrome. Nur setzen, wo er woanders liegt – +# ein Pfad, der ins Leere zeigt, ist ein Fehler und kein Rueckfall auf die Suche. +CHROMIUM_PFAD= +# Wie viele PDFs gleichzeitig entstehen duerfen. Jedes haelt kurz einen +# Browser-Kontext offen; mehr als eine Handvoll bringt auf kleinen Maschinen nur +# Arbeitsspeicherdruck. Darueber hinaus bekommt der Anwender die Bitte, es gleich +# noch einmal zu versuchen. +PDF_MAX_GLEICHZEITIG=2 +# Nach wie vielen Millisekunden Leerlauf der Chromium wieder schliesst. Er haelt +# offen rund 100 MB belegt – auf einem 2-GB-LXC ist das spuerbar. +PDF_LEERLAUF_MS=60000 + # ---------- Tests ---------- # Datenbank fuer Vitest. Ohne diese Variable ueberspringen sich die Datenbanktests. diff --git a/CHANGELOG.md b/CHANGELOG.md index c5d8bf9..d28260a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,23 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Hinzugefügt +- **Das Übergabeprotokoll als PDF.** Neben *Drucken* steht am Lauf **Als PDF**, im Archiv an jedem + Treffer **Protokoll als PDF** — dasselbe Blatt als fertige Datei, zum Verschicken oder Abheften. + Unten darauf jetzt zwei Unterschriftsfelder (*Übergeben von* / *Übernommen von*) mit Zeile für + Ort, Datum und Namen; wo eine Vier-Augen-Freigabe erteilt wurde, steht sie mit im Kopf. + Erzeugt wird es, indem der Server seine **eigene** Druckansicht lädt und druckt: es gibt genau + eine Vorlage für Papier und Datei, und für das PDF gilt dieselbe Rechte- und Mandantenprüfung wie + für die Seite. Gespeichert wird nichts — ein abgelegtes PDF wäre eine zweite Wahrheit neben dem + Lauf. **Ein erzeugtes Passwort steht auch hier nie darauf**, geprüft am extrahierten Text des + fertigen PDF (#041) +- **Chromium im Bild ist dafür nötig — und bleibt optional.** Das Laufzeitbild wächst von + **370 MB auf 1,52 GB**; Alpines `chromium` kostet allein 1,13 GB, mehr als das Dreifache der + Anwendung. Deshalb kommt er nur mit `docker build --build-arg MIT_PDF=1` beziehungsweise + `deploy/install.sh --mit-pdf` mit. **Fehlt er, erscheint der Knopf gar nicht erst**, und ein + direkter Aufruf antwortet `503` mit einer Meldung, die sagt, was fehlt und wo es steht — kein + leeres PDF. Die Druckansicht bleibt in jedem Fall der Weg. Einstellbar sind `CHROMIUM_PFAD`, + `PDF_MAX_GLEICHZEITIG` (2) und `PDF_LEERLAUF_MS` (60 s); alles in + [`docs/betrieb/pdf.md`](docs/betrieb/pdf.md) (#041) - **Vier-Augen-Freigabe.** Verlangt eine Vorlagen-Version sie, lässt sich ein Lauf erst abschließen, wenn ein **Zweiter** ihn freigegeben hat — jemand mit dem neuen Recht `freigeben` (`ADMIN` und `REDAKTEUR`, **nicht** `TECHNIKER`), der nicht an dem Lauf gearbeitet hat. Als gearbeitet zählt: diff --git a/CLAUDE.md b/CLAUDE.md index 1fd0a7b..9464213 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -242,6 +242,7 @@ brauchen einen echten Browser. | Druckansicht und Etiketten | Print-CSS | | Eine Rolle sieht keine Schreib-Knöpfe | gerendertes DOM | | Dass überhaupt hydriert wird | der Grund für „gebauter Stand“, siehe oben | +| PDF-Protokoll ohne erzeugtes Passwort | das PDF entsteht erst im echten Chromium (#041) | **Was sich holen ließ, ist geholt.** Die Rechteprüfung der Route Handler steht seit #036 in `tests/anhang-route.test.ts`, `tests/lauf-ereignisse.test.ts` und `tests/scim.test.ts` — die Handler @@ -276,6 +277,13 @@ geprüft. In CI bricht sie stattdessen ab, absichtlich. `ldap` fiel bei der Abnahme zu #019 auf, `mailpit` kam mit #034 dazu. Beide Zahlen sind **gemessen**, nicht geschätzt — in #026 hätte beinahe eine geratene in dieser Datei gestanden. +**`pdftotext` gehört zur E2E-Ausstattung, nicht zum Commit-Tor.** Zwei Abläufe lesen den Text des +erzeugten PDF — darunter die Zusage „kein erzeugtes Passwort auf dem Blatt“. Der Inhalt eines PDF +ist komprimiert: in den rohen Bytes zu suchen und nichts zu finden beweist **nichts**. Fehlt +`poppler-utils`, überspringen sich beide und sagen das auf stderr. Ebenso überspringen sie sich, +wenn der Server kein Chromium hat — dann erscheint der Knopf „Als PDF“ gar nicht, und das ist eine +gültige Installation (`docs/betrieb/pdf.md`). + `format:pruefen` gehört dazu, seit der Review in #010 darauf gestoßen ist: `lint-staged` formatiert beim Commit automatisch nach, und das Ergebnis wäre ein Commit mit Änderungen, die niemand gesehen hat. Lieber vorher sehen, was sich ändert. diff --git a/Dockerfile b/Dockerfile index 83ff504..894f760 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,35 @@ FROM basis AS laufzeit ENV NODE_ENV=production ENV PORT=3000 ENV HOSTNAME=0.0.0.0 + +# --------------------------------------------------------------------------- +# Chromium fuer das PDF-Uebergabeprotokoll – optional, und das mit Absicht (#041). +# +# Gemessen am 2026-10-25, und die Zahl ist der Grund fuer den Schalter: +# +# ohne Chromium 370 MB +# mit Chromium 1,52 GB +# +# Alpines `chromium` kostet allein 1,13 GB – mehr als das dreifache der +# gesamten Anwendung. Die meisten Installationen brauchen es nicht: die +# Druckansicht gibt dasselbe Blatt ueber den Browser des Anwenders aus. +# +# `font-noto` sind davon nur 20 MB (`ttf-dejavu` waeren 10 MB und deckten +# weniger ab). Es muss mit: Chromium ohne jede Systemschrift stellt Zeichen als +# leere Kaesten dar, sobald eines nicht in unseren eigenen Schriften steht – +# und das PDF waere trotzdem ein gueltiges PDF. Ein Fehler, der nicht meldet, +# sondern falsch liefert. +# +# docker build --build-arg MIT_PDF=1 -t laufzettel . +# +# Ohne das Argument sagt der Endpunkt, was fehlt, und die Oberflaeche zeigt den +# Knopf gar nicht erst (docs/betrieb/pdf.md). +# --------------------------------------------------------------------------- +ARG MIT_PDF=0 +RUN if [ "$MIT_PDF" = "1" ]; then \ + apk add --no-cache chromium font-noto; \ + fi + RUN addgroup -g 1001 -S laufzettel && adduser -S laufzettel -u 1001 -G laufzettel COPY --from=bauen --chown=laufzettel:laufzettel /app/.next/standalone ./ COPY --from=bauen --chown=laufzettel:laufzettel /app/.next/static ./.next/static diff --git a/deploy/install.sh b/deploy/install.sh index 9559c62..0c31f47 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -54,6 +54,8 @@ bindAdresse="${BIND_ADDR:-127.0.0.1}" seedModus="${SEED_MODE:-INTERN}" passwortDatei="${LAUFZETTEL_PASSWORT_DATEI:-}" testmodus=0 +# PDF-Uebergabeprotokoll: Chromium kostet ueber 1 GB, also nur auf Verlangen (#041). +mitPdf="${LAUFZETTEL_MIT_PDF:-0}" # Nur die Werte, die in die Umgebungsdatei wandern - Quelle und Zweig gelten # auch beim Aktualisieren und loesen deshalb keinen Hinweis aus. optionenGesetzt=0 @@ -136,6 +138,12 @@ Schalter: die es nicht von der Ausgabe ablesen koennen. Wer sie benutzt, loescht sie nach dem Ablesen: sonst liegt das Passwort dauerhaft im Klartext im System. + --mit-pdf Chromium mitinstallieren, damit das Uebergabeprotokoll + als PDF entsteht. Kostet rund 1,1 GB Plattenplatz und + ist deshalb nicht Standard — ohne ihn bleibt die + Druckansicht der Weg, und der Knopf erscheint gar nicht + erst. Wirkt auch bei einem zweiten Lauf, weil es ein + Paket ist und keine Einstellung. docs/betrieb/pdf.md --test Nach der Installation eine Selbstpruefung fahren: kein TCP-Port 5432, Migration und Seed gelaufen, Antwort auf /api/gesundheit. Ohne systemd (Container) wird der Dienst @@ -152,7 +160,8 @@ Umgebung: APP_URL, BIND_ADDR, APP_PORT, SEED_ADMIN_EMAIL, SEED_MODE, LAUFZETTEL_REPO (wie --quelle), LAUFZETTEL_ZWEIG (wie --zweig), - LAUFZETTEL_PASSWORT_DATEI (wie --passwort-datei) + LAUFZETTEL_PASSWORT_DATEI (wie --passwort-datei), + LAUFZETTEL_MIT_PDF=1 (wie --mit-pdf) Das Administrator-Passwort steht nicht in /etc/laufzettel/laufzettel.env. Es wird beim ersten Lauf einmal angezeigt und liegt sonst nur als argon2-Hash in @@ -305,6 +314,15 @@ paketeInstallieren() { if ! dpkgInstalliert "$paket"; then fehlend+=("$paket"); fi done + # Chromium und eine Rueckfallschrift nur auf Verlangen: zusammen rund 1,1 GB. + # Ohne die Schriften entsteht ein PDF mit leeren Kaesten statt Buchstaben -- + # ein gueltiges PDF, das falsch ist, und das ist schlimmer als keines. + if [ "$mitPdf" = "1" ]; then + for paket in chromium fonts-noto-core; do + if ! dpkgInstalliert "$paket"; then fehlend+=("$paket"); fi + done + fi + if [ "${#fehlend[@]}" -gt 0 ]; then aptAktualisieren apt-get install -y --no-install-recommends "${fehlend[@]}" >/dev/null @@ -312,6 +330,12 @@ paketeInstallieren() { else melde "alles vorhanden" fi + + if [ "$mitPdf" = "1" ]; then + melde "PDF-Uebergabeprotokoll: $(command -v chromium || echo 'Chromium NICHT gefunden')" + else + hinweis "Ohne --mit-pdf entsteht kein PDF-Uebergabeprotokoll; die Druckansicht bleibt der Weg." + fi } # ---------- Schritt 3: Dienstnutzer und Verzeichnisse ----------------------- @@ -1022,6 +1046,10 @@ schalterLesen() { passwortDatei="${2:?--passwort-datei braucht einen Pfad}" shift 2 ;; + --mit-pdf) + mitPdf=1 + shift + ;; --test) testmodus=1 shift diff --git a/docs/anleitung/02-laeufe-abarbeiten.md b/docs/anleitung/02-laeufe-abarbeiten.md index ab5740f..efeb227 100644 --- a/docs/anleitung/02-laeufe-abarbeiten.md +++ b/docs/anleitung/02-laeufe-abarbeiten.md @@ -321,6 +321,29 @@ abheftet, darf keines stehen. Von der Person stehen nur Name, UPN und Abteilung auf dem Beleg. Ein Ausdruck liegt herum, wird kopiert und weitergegeben; was darauf steht, ist aus der Hand gegeben. +Unten auf dem Blatt stehen zwei Unterschriftsfelder — **Übergeben von** und **Übernommen von**, je +mit einer Zeile für Ort, Datum und Namen in Druckbuchstaben. Damit ist der Beleg gleich das +Übergabeprotokoll: Gerät wechselt den Besitzer, beide unterschreiben, fertig. + +### Als PDF + +Neben **Drucken** steht **Als PDF**. Der Knopf lädt dasselbe Blatt als fertige PDF-Datei +herunter — ohne den Umweg über den Druckdialog. Praktisch für den, der ein Protokoll verschicken +oder ablegen will statt es auf Papier zu bringen. + +Im **Archiv** steht an jedem Treffer derselbe Knopf, dort als **Protokoll als PDF**. Wer ein altes +Protokoll für die Ablage oder für einen Kunden braucht, holt es damit direkt aus der Trefferliste, +ohne den Lauf erst zu öffnen. + +> **Fehlt der Knopf, ist er nicht kaputt.** Die PDF-Erzeugung braucht ein zusätzliches Programm auf +> dem Server, und das ist nicht überall installiert — es kostet mehr Platz als die ganze Anwendung. +> Dann bleibt **Drucken** der Weg: im Browser „in eine Datei drucken“ liefert dasselbe Blatt. +> Wer es dauerhaft haben will, fragt den Administrator; die Einzelheiten stehen in +> [`docs/betrieb/pdf.md`](../betrieb/pdf.md). + +Auf dem PDF steht genau dasselbe wie auf dem gedruckten Blatt — es **ist** dieselbe Ansicht. Auch +hier also kein erzeugtes Passwort. + ## Im Archiv suchen ![Das Archiv mit Suchfeld, Filtern und Fundstellen im Treffer](bilder/archiv-desktop.png) diff --git a/docs/betrieb/README.md b/docs/betrieb/README.md index f912ce2..eacd9ef 100644 --- a/docs/betrieb/README.md +++ b/docs/betrieb/README.md @@ -62,6 +62,9 @@ Sitzungen ungültig *und* die verschlüsselt gespeicherten LDAP-Bind-Passwörter Signatur nachrechnen, und wo man nachsieht, wenn keine Mail mehr ankommt - **[SCIM-Endpunkt](scim.md)** — die einzige Stelle, an der ein fremder Dienst von außen schreibend zugreift: das Token, was nicht unterstützt wird, und was der Reverse Proxy durchlassen muss +- **[Das Übergabeprotokoll als PDF](pdf.md)** — eine Zusatzausstattung, die Chromium verlangt und + das Bild von 370 MB auf 1,52 GB bringt: wann sie sich lohnt, wie sie dazukommt, und was passiert, + wenn man sie weglässt ## Wo sonst noch etwas steht diff --git a/docs/betrieb/inbetriebnahme.md b/docs/betrieb/inbetriebnahme.md index 48e77df..158ae7b 100644 --- a/docs/betrieb/inbetriebnahme.md +++ b/docs/betrieb/inbetriebnahme.md @@ -91,6 +91,7 @@ Jede steht mit Kommentar in [`../../.env.example`](../../.env.example). **Pflich | `SEED_ADMIN_EMAIL`, `SEED_ADMIN_PASSWORD` | Der Administrator, den der Seed anlegt. Das Passwort wird nur als argon2-Hash gespeichert. Fehlt eines, überspringt der Seed ihn — und dann kommst du nicht hinein. | | `SEED_MODE` | `INTERN` (Standard) oder `DIENSTLEISTUNG`, `SERVICE` als Alias. Betrifft nur den **ersten** Seed-Lauf; danach steht der Modus in der Datenbank und wird in der Oberfläche umgeschaltet. | | `SEED_ORG_NAME`, `SEED_ORG_KUERZEL` | Name und Kürzel der eigenen Organisation. Das Kürzel (2 bis 6 Großbuchstaben oder Ziffern) steht auf Etiketten und in der Hostname-Regel. | +| `CHROMIUM_PFAD`, `PDF_MAX_GLEICHZEITIG`, `PDF_LEERLAUF_MS` | Nur wenn das Übergabeprotokoll als PDF entstehen soll. Das verlangt Chromium im Bild (`--build-arg MIT_PDF=1`) und bringt es von 370 MB auf 1,52 GB; ohne ihn erscheint der Knopf nicht und die Druckansicht bleibt der Weg. Alles dazu in [`pdf.md`](pdf.md). | | `TEST_DATABASE_URL` | Nur für die Tests. Im Betrieb ohne Bedeutung. | ## 4. Starten diff --git a/docs/betrieb/pdf.md b/docs/betrieb/pdf.md new file mode 100644 index 0000000..89c556d --- /dev/null +++ b/docs/betrieb/pdf.md @@ -0,0 +1,167 @@ +# Das Übergabeprotokoll als PDF + +Jeder Lauf lässt sich als PDF herunterladen — ein Blatt mit Kopfdaten, allen Schritten samt +Zuständen und Notizen, den Kommentaren und zwei Unterschriftsfeldern. Gedacht ist es für die +Übergabe: ein Gerät wechselt den Besitzer, beide unterschreiben, das Blatt geht in die Ablage oder +zum Kunden. + +**Das ist eine Zusatzausstattung, und sie kostet.** Ohne sie fehlt nichts Wesentliches: die +Druckansicht zeigt dasselbe Blatt, und jeder Browser kann daraus „in eine Datei drucken“. Das PDF +vom Server nimmt diesen Schritt nur ab — für den, der zwanzig Protokolle am Stück braucht, oder für +den, der sie aus dem Archiv heraus einzeln verschickt. + +## Was es braucht + +**Chromium auf dem Server.** Nichts weiter — keine Schriften aus dem Netz, kein Dienst, kein +zusätzlicher Port. + +Der Grund: das PDF entsteht, indem der Server seine **eigene** Druckansicht lädt, so wie ein +Browser es täte, und sie druckt. Dadurch gibt es genau eine Vorlage für Papier und PDF. Ein zweites +Layout im Code wäre billiger zu betreiben und würde früher oder später von der Druckansicht +abweichen, ohne dass es jemandem auffällt — bis ein Kunde zwei verschiedene Blätter nebeneinander +legt. + +Chromium ist der Preis dafür, und er ist hoch: + +| Laufzeitbild | Größe | +|---|---| +| ohne Chromium | **370 MB** | +| mit Chromium | **1,52 GB** | + +Gemessen am 2026-10-25. Alpines `chromium` kostet allein 1,13 GB — mehr als das Dreifache der +gesamten Anwendung. Deshalb ist er nicht im Standardbild, und deshalb ist diese Seite kein Anhang, +sondern eine Entscheidung. + +## Mit Docker Compose + +Chromium kommt über ein Bau-Argument ins Bild: + +```bash +docker compose build --build-arg MIT_PDF=1 web +docker compose up -d +``` + +Damit das beim nächsten `docker compose build` nicht verlorengeht, gehört es in die +`docker-compose.yml` beziehungsweise in eine Override-Datei: + +```yaml +services: + web: + build: + args: + MIT_PDF: "1" +``` + +Mehr ist nicht zu tun: das Paket liegt dann unter `/usr/bin/chromium-browser`, und dort sucht die +Anwendung von sich aus. + +## Nativ, im LXC oder auf einem Debian + +`deploy/install.sh` fragt beim ersten Lauf, ob Chromium mit soll, und merkt die Antwort in +`/etc/laufzettel/laufzettel.env`. Nachträglich: + +```bash +apt-get install -y chromium +systemctl restart laufzettel +``` + +Auf Debian 13 liegt es danach unter `/usr/bin/chromium`. Der Neustart ist nötig, weil die Anwendung +den Pfad beim ersten PDF sucht und sich das Ergebnis für die Laufzeit des Prozesses merkt. + +## Wenn Chromium fehlt + +**Dann passiert nichts Stilles.** Der Knopf „Als PDF“ erscheint am Lauf und im Archiv gar nicht +erst — die Anwendung sieht bei jedem Seitenaufbau nach, ob ein Chromium da ist. Wer den Endpunkt +trotzdem aufruft, etwa aus einem alten Tab oder mit einem Lesezeichen, bekommt `503` und diesen +Text: + +> Auf diesem Server ist kein Chromium installiert, und ohne ihn entsteht kein PDF. Die Druckansicht +> funktioniert weiterhin: dort im Browser „in eine Datei drucken“. Wie Chromium dazukommt, steht in +> docs/betrieb/pdf.md. + +Ein leeres oder halbes PDF gibt es in keinem Fall. Das war die Vorgabe für dieses Verhalten: ein +Blatt, das aussieht wie ein Protokoll und keines ist, wäre schlimmer als eine klare Absage. + +## Die drei Einstellungen + +Alle drei sind Vorgaben, die man kennen sollte und selten braucht. + +| Variable | Vorgabe | Wofür | +|---|---|---| +| `CHROMIUM_PFAD` | leer | Wo Chromium liegt, wenn er woanders liegt | +| `PDF_MAX_GLEICHZEITIG` | `2` | Wie viele PDFs gleichzeitig entstehen dürfen | +| `PDF_LEERLAUF_MS` | `60000` | Nach wie viel Leerlauf der Browser wieder schließt | + +**`CHROMIUM_PFAD` leer lassen heißt „suche selbst“** — der Reihe nach +`/usr/bin/chromium-browser`, `/usr/bin/chromium`, `/usr/bin/google-chrome`. Ein gesetzter Pfad, der +ins Leere zeigt, ist dagegen ein **Fehler** und kein Rückfall auf die Suche: wer hier etwas +einträgt, hat eine Absicht, und ein Tippfehler soll auffallen und nicht dazu führen, dass +stillschweigend ein anderer Browser läuft als der gemeinte. + +**`PDF_MAX_GLEICHZEITIG` schützt den Arbeitsspeicher.** Jedes PDF hält kurz einen eigenen +Browser-Kontext offen — eigen, und nicht geteilt, weil er das Sitzungscookie des Anfragenden trägt. +Ist die Grenze erreicht, bekommt der nächste die Bitte, es gleich noch einmal zu versuchen, statt +einer Maschine, die unter Last steht. + +**`PDF_LEERLAUF_MS`** hält den Chromium nach dem letzten PDF noch eine Minute offen, weil ihn zu +starten rund zwei Sekunden kostet und Protokolle meist in Serie entstehen. Danach schließt er: offen +belegt er rund 100 MB, und auf einem 2-GB-LXC ist das ein Achtel des Arbeitsspeichers für etwas, das +niemand gerade braucht. + +## Was auf dem Blatt steht — und was nicht + +Es steht dasselbe darauf wie in der Druckansicht, denn es **ist** die Druckansicht. Insbesondere: + +- **Kein generiertes Passwort.** Wo ein Befehl `{{generiertes_passwort}}` enthält, steht auf dem + Blatt `[Passwort – nicht gespeichert]`. Das Passwort entsteht bei jeder Anzeige neu und wird + nirgends gespeichert; ein Ausdruck dagegen liegt herum. Geprüft wird das am extrahierten Text des + fertigen PDF, nicht an der Vorlage. +- **Keine Anhänge**, nur ihre Anzahl. Ein Foto gehört nicht auf ein Übergabeprotokoll, und ein PDF + mit zwanzig Bildern wäre nicht mehr zu verschicken. +- **Befehle unmaskiert.** Das Blatt hält fest, was zu tun war; Maskierzeichen wären auf Papier + Rauschen. Wer einen Befehl ausführen will, nimmt die Laufseite — dort ist er für die gewählte + Shell maskiert. + +## Fehlersuche + +**Der Knopf fehlt, obwohl Chromium installiert ist.** Die Anwendung hat ihn nicht gefunden. Nachsehen, +wo er liegt, und `CHROMIUM_PFAD` setzen: + +```bash +command -v chromium chromium-browser google-chrome +``` + +Danach den Dienst neu starten. Im Compose-Stand prüft man im Container selbst, nicht auf dem Host: + +```bash +docker compose exec web command -v chromium-browser +``` + +**`503` mit „Die Druckansicht antwortete mit 307“** (oder 302, oder 401). Das Sitzungscookie kam +nicht mit oder gilt nicht mehr — Chromium wurde auf die Anmeldeseite geschickt. Neu anmelden und es +noch einmal versuchen. Bleibt es dabei, steht vermutlich ein Reverse Proxy im Weg, der `Cookie` +nicht durchlässt. + +**`503` mit „Es entstehen gerade … PDFs gleichzeitig“.** Die Grenze greift. Entweder warten oder +`PDF_MAX_GLEICHZEITIG` anheben — und dabei im Blick behalten, dass jeder gleichzeitige Vorgang +Arbeitsspeicher kostet. + +**`500` mit „Einzelheiten stehen im Protokoll des Dienstes“.** Hier geht die Meldung absichtlich +nicht hinaus, weil eine rohe Ausnahme Pfade und Zugangsdaten nennen kann. Nachsehen: + +```bash +docker compose logs -f web | grep PDF # Compose +journalctl -u laufzettel -f | grep PDF # nativ +``` + +**Leere Kästen statt Buchstaben im PDF.** Dem Chromium fehlen Systemschriften. Im Bild ist +`font-noto` deshalb mit dabei; nativ gehört es nachinstalliert +(`apt-get install -y fonts-noto-core`). Die Schriften der Oberfläche selbst liefert die Anwendung +mit, aber für Zeichen, die darin nicht vorkommen, braucht Chromium einen Rückfall. + +## Wo sonst noch etwas steht + +- [`../../.env.example`](../../.env.example) — die drei Variablen mit Kommentar +- [`inbetriebnahme.md`](inbetriebnahme.md) — der Compose-Weg im Ganzen +- [`proxmox.md`](proxmox.md) — der native Weg und `deploy/install.sh` +- [`../anleitung/02-laeufe-abarbeiten.md`](../anleitung/02-laeufe-abarbeiten.md) — wie man das Protokoll als Anwender holt diff --git a/docs/devlog.md b/docs/devlog.md index 487f79a..e7a4a93 100644 --- a/docs/devlog.md +++ b/docs/devlog.md @@ -42,13 +42,14 @@ als Unterpunkte mit Aufgabe und Dauer. | #037 | Zwei Nachzügler aus der Durchsicht von Phase 2 | 1 h 50 min | | #039 | M23 – Bedingte Schritte und Abschnitte | 5 h 20 min | | #040 | M24 – Vier-Augen-Freigabe | 3 h 30 min | -| | **Summe** | **82 h 45 min** | +| #041 | M25 – PDF-Übergabeprotokoll mit Unterschrift | 4 h 10 min | +| | **Summe** | **86 h 55 min** | | Meilenstein | Tatsächlich | Status | |---|---|---| | v0.1.0 | 35 h 20 min | veröffentlicht | | v0.2.0 | 38 h 35 min | veröffentlicht | -| v0.3.0 | 8 h 50 min | in Arbeit | +| v0.3.0 | 13 h 00 min | in Arbeit | --- @@ -1895,3 +1896,80 @@ Wirklichkeit nicht — die Prüflisten stehen in `docs/betrieb/entra-id.md` und ### Nächster Schritt - **M25 (#041)** PDF-Übergabeprotokoll. Der Freigeber steht jetzt am Lauf und gehört auf das Blatt. + +--- + +## 2026-10-25 · Sitzung 27 · 09:30–13:40 + +**Issue:** #041 (M25) +**Version:** 0.3.0 (unveröffentlicht) + +### Erledigt + +- **Das Übergabeprotokoll als PDF.** `GET /api/laeufe/[id]/protokoll.pdf`, Chromium-Vorrat mit + Obergrenze und Leerlauf-Schließung, Knopf am Lauf und an jedem Archiv-Treffer, + Unterschriftsfelder und Freigabe-Zeile auf dem Blatt. +- **Chromium optional ins Bild**: `--build-arg MIT_PDF=1`, `deploy/install.sh --mit-pdf`, + `docs/betrieb/pdf.md`, die drei Variablen in `.env.example` und in der Variablentabelle, + Anleitung Kapitel 02. +- 1545 Vitest-Tests (vorher 1526): 15 in `tests/pdf.test.ts`, 3 in `tests/pdf-grenze.test.ts`, + einer in `tests/archiv.test.ts`. Dazu drei E2E-Abläufe. Volle E2E-Suite grün: 118 bestanden, + 40 übersprungen. +- **Abnahme von Hand:** ein Blatt wirklich erzeugt und angesehen — zwei Seiten A4, 138 kB, + Kopfdaten, sieben Schritte mit Zuständen und Notizen, Befehl, Kommentar, beide + Unterschriftsfelder mit sichtbarer Linie, Fußzeile mit Druckzeitpunkt und Lauf-Kennung. Umlaute + stehen richtig da, also stimmen die Schriften. + +### Entscheidungen + +- **`playwright-core` als echte Abhängigkeit**, nicht `@playwright/test`: das ist ein + Entwicklungspaket und hat im Laufzeitbild nichts verloren. Es landet über das Tracing im + Standalone-Bündel (12 MB, als relativer Symlink in das mitkopierte `.pnpm`). +- **Ein eigener Browser-Kontext je PDF, kein geteilter.** Er trägt das Sitzungscookie des + Anfragenden; ein geteilter behielte es und gäbe das nächste PDF mit fremder Sitzung heraus. +- **Der Server ruft sich über `127.0.0.1` selbst**, nicht über `APP_URL`. Das wäre ein Weg nach + draußen und zurück, mit TLS, Proxy-Regeln und einer zweiten Stelle, an der etwas klemmt. +- **Der Knopf erscheint nur, wenn Chromium da ist.** Ein Knopf, der zuverlässig in eine + Fehlermeldung führt, ist schlimmer als kein Knopf — die Druckansicht daneben tut dasselbe, und + zwar immer. `pdfVerfuegbar()` wirft deshalb nie. +- **Ein gesetztes, aber falsches `CHROMIUM_PFAD` ist ein Fehler**, kein Rückfall auf die Suche. Wer + dort etwas einträgt, hat eine Absicht; still auszuweichen hieße, dass ein Tippfehler unbemerkt + bleibt und ein anderer Browser läuft als der gemeinte. +- **Die Unterschriftslinie nimmt `border-text`**, nicht `border-black`. `.druck-hell` setzt + `--color-text` auf `#0b0d10` — damit tut das Token genau das, was der rohe Wert sollte, und + CLAUDE.md braucht keine siebte Ausnahme. + +### Probleme + +- **Die Bildgröße war um das Dreieinhalbfache danebengeschätzt.** Ich hatte „+328 MB“ im Kopf — das + ist das `.apk`-Paket. Gemessen: ohne Chromium **370 MB**, mit **1,52 GB**, Chromium allein also + 1,13 GB. Die geschätzte Zahl stand schon im Dockerfile-Kommentar, bevor ich gebaut habe. Genau + die Art Zusage, die CLAUDE.md anprangert — und diesmal hat die Regel „gemessen, nicht geschätzt“ + sie noch vor dem Commit gefangen. Nebenbei: `font-noto` sind davon nur 20 MB, die Schriften waren + nie das Problem. +- **Ein Server aus einer früheren Sitzung lief noch auf Port 3300.** Die Klickprobe zeigte deshalb + keinen PDF-Knopf, und ich habe zuerst `pdfVerfuegbar()` verdächtigt. Der Prozess trug alten Code + und kein `CHROMIUM_PFAD`. Eine halbe Stunde an einem Fehler, den es nicht gab — vor einer Probe + am gebauten Stand gehört geprüft, **welcher** Stand da läuft. +- **`tests/zugang.test.ts` blieb grün, als ich die Rechteprüfung des Handlers durch einen festen + ADMIN ersetzt habe.** Es sucht nach dem Vorkommen von `rechtErforderlich`, und der Import stand + noch da. Das ist die bekannte Grenze eines strukturellen Tests — er erzwingt, dass jemand *etwas* + schreibt, nicht dass es wirkt. Genau deshalb fahren `tests/pdf.test.ts` und die + Geschwisterdateien den Handler echt, und genau die wurden rot. +- **Die Obergrenze hatte zuerst keinen eigenen Prüfer.** Geprüft war `maxPdfs()` und der + 503-Zweig des Handlers mit gemocktem Fehler — nicht, dass `seiteAlsPdf` die Grenze wirklich + durchsetzt. `tests/pdf-grenze.test.ts` fährt die echte Funktion mit gestelltem Browser; die + Gegenprobe (Freigabe aus dem `finally` nehmen) deckt den Fall auf, in dem ein Fehlschlag den + Zähler nie zurücksetzt und die PDF-Erzeugung bis zum Neustart leise stillsteht. +- **Mein Passwort-Suchmuster warf beim Bauen.** `\\-` in einer Vorlagenzeichenkette wird zu `\-`, + und mit dem `u`-Schalter ist das eine ungültige Maskierung. Der Bindestrich gehört ans Ende der + Zeichenklasse, wo er sich selbst meint. +- **Gegenproben, alle angeschlagen:** Mandantenprüfung ausgehebelt (2 Tests rot), Rechteprüfung + durch festen ADMIN ersetzt (3 rot), Cookie nicht weitergegeben (1 rot), Obergrenze abgeschaltet + (2 rot), Freigabe aus dem `finally` (1 rot) und — die wichtigste — ein echtes Passwort auf das + Blatt gelassen: der neue E2E-Ablauf fand es, zusammen mit dem bestehenden Druckansicht-Test. + +### Nächster Schritt + +- **M26 (#042)** weitere Schritt-Typen. Die Unterschrift mit dem Finger gehört dorthin; sie landet + dann als Bild im Schritt und damit ohnehin auf diesem Blatt. diff --git a/docs/issues/041-pdf-uebergabeprotokoll.md b/docs/issues/041-pdf-uebergabeprotokoll.md index adf0eff..be8d103 100644 --- a/docs/issues/041-pdf-uebergabeprotokoll.md +++ b/docs/issues/041-pdf-uebergabeprotokoll.md @@ -1,10 +1,10 @@ # #041 M25 – PDF-Übergabeprotokoll mit Unterschrift -**Status:** offen +**Status:** erledigt **Im Tracker:** – **Meilenstein:** v0.3.0 **Labels:** phase-3, backend, frontend -**Geschätzt:** 8 h **Tatsächlich:** – +**Geschätzt:** 8 h **Tatsächlich:** 4 h 10 min ## Ziel @@ -17,19 +17,19 @@ Kopfzeilen und lässt sich nicht an ein Ticket anhängen. ## Zu entscheiden -- [ ] **Wie entsteht das PDF?** Empfehlung: **Chromium aus `@playwright/test`**, wie +- [x] **Wie entsteht das PDF?** Empfehlung: **Chromium aus `@playwright/test`**, wie `scripts/anleitung-pdf.ts` es für die Anleitung tut. Keine neue Abhängigkeit, keine zweite Auslegung des Layouts — gedruckt wird dieselbe Seite, die die Druckansicht zeigt. **Grenze, die genannt gehört:** damit braucht der Server einen Chromium. Im Compose-Stand ist das eine Ergänzung am Image, nativ ein Paket. Die Alternative (`pdf-lib` oder ein Dienst) wäre ein zweites Layout, das auseinanderläuft — der Preis ist höher. -- [ ] **Unterschrift auf Papier oder auf dem Gerät?** Empfehlung: **beides, in dieser Reihenfolge.** +- [x] **Unterschrift auf Papier oder auf dem Gerät?** Empfehlung: **beides, in dieser Reihenfolge.** Zuerst ein Unterschriftsfeld im PDF (eine Linie, Name, Datum) — das löst den Fall heute und kostet fast nichts. Eine Unterschrift mit dem Finger auf dem Handy ist ein eigener Schritt-Typ und gehört zu [#042](042-weitere-schritt-typen.md); sie landet dann als Bild im PDF. Eine digitale **Signatur** im kryptografischen Sinn ist ausdrücklich **nicht** gemeint und wäre eine eigene Phase. -- [ ] **Wird das PDF gespeichert oder bei jedem Abruf erzeugt?** Empfehlung: **bei jedem Abruf +- [x] **Wird das PDF gespeichert oder bei jedem Abruf erzeugt?** Empfehlung: **bei jedem Abruf erzeugt**, nicht gespeichert. Ein gespeichertes PDF ist eine zweite Wahrheit neben dem Lauf; wird eine Notiz später berichtigt, stimmt es nicht mehr. Für den Fall „wir brauchen das Blatt von damals“ gibt es das Archiv, das den Lauf unveränderlich hält. @@ -38,31 +38,51 @@ Kopfzeilen und lässt sich nicht an ein Ticket anhängen. ## Aufgaben -- [ ] `GET /api/laeufe/[id]/protokoll.pdf` — Route Handler, prüft sein Recht **selbst** +- [x] `GET /api/laeufe/[id]/protokoll.pdf` — Route Handler, prüft sein Recht **selbst** (`tests/zugang.test.ts` erzwingt das seit #038) -- [ ] Erzeugung über Chromium, gegen die eigene Druckansicht; eine Seitenvorlage mit Kopf (Kunde, +- [x] Erzeugung über Chromium, gegen die eigene Druckansicht; eine Seitenvorlage mit Kopf (Kunde, Lauf, Vorlage, Version), Inhalt und Fuß (Bearbeiter, Freigeber aus [#040](040-vier-augen-freigabe.md), Unterschriftsfeld) -- [ ] Ein **Chromium-Vorrat** im Prozess: ihn je Anfrage zu starten kostet Sekunden. Eine Instanz, +- [x] Ein **Chromium-Vorrat** im Prozess: ihn je Anfrage zu starten kostet Sekunden. Eine Instanz, die nach Leerlauf schließt — und eine Obergrenze gleichzeitiger Erzeugungen, wie `SSE_MAX_STROEME` in #033 -- [ ] Knopf „Protokoll als PDF“ an Lauf und Archiv -- [ ] `Dockerfile` und `deploy/install.sh` um Chromium ergänzen, **mit gemessener Bildgröße** im +- [x] Knopf „Protokoll als PDF“ an Lauf und Archiv +- [x] `Dockerfile` und `deploy/install.sh` um Chromium ergänzen, **mit gemessener Bildgröße** im Changelog — eine Ergänzung am Image, die niemand bemerkt, ist eine böse Überraschung beim nächsten Rollout -- [ ] `docs/betrieb/` sagt, was die Erzeugung braucht und was passiert, wenn Chromium fehlt: +- [x] `docs/betrieb/` sagt, was die Erzeugung braucht und was passiert, wenn Chromium fehlt: **eine Meldung, kein stilles leeres PDF** -- [ ] Anleitung Kapitel 02 und 10 (Archiv) +- [x] Anleitung Kapitel 02 und 10 (Archiv) + +## Wie entschieden wurde + +**Chromium gegen die eigene Druckansicht**, wie empfohlen — aber über `playwright-core` als echte +Abhängigkeit, nicht über `@playwright/test`: das ist ein Entwicklungspaket und hat im +Laufzeitbild nichts verloren. Gedruckt wird `/drucken/lauf/[id]` über `127.0.0.1`, mit dem +Sitzungscookie des Anfragenden; damit gilt für das PDF dieselbe Rechte- und Mandantenprüfung wie +für die Seite, und es gibt genau eine Vorlage für Papier und Datei. + +**Chromium ist optional und nicht im Standardbild.** Die gemessene Zahl war das Argument: das +Laufzeitbild wächst von **370 MB auf 1,52 GB**, Alpines `chromium` kostet allein 1,13 GB. Das Bild +baut mit `--build-arg MIT_PDF=1`, nativ installiert `deploy/install.sh --mit-pdf`. Ohne Chromium +erscheint der Knopf gar nicht erst, und der Endpunkt antwortet `503` mit einer Meldung, die auf +`docs/betrieb/pdf.md` verweist — kein stilles leeres PDF. + +Die Schätzung war damals auf „+328 MB“ hochgerechnet. Das war das `.apk`-Paket, nicht das Bild; +gemessen ist es das Dreieinhalbfache. Deshalb steht die Zahl jetzt an drei Stellen gemessen da. + +**Nicht gespeichert, bei jedem Abruf erzeugt** — wie empfohlen. Das Unterschriftsfeld ist eine +Linie auf Papier; die Unterschrift mit dem Finger bleibt [#042](042-weitere-schritt-typen.md). ## Akzeptanzkriterien -- [ ] Test: das PDF enthält Titel, Vorlage mit Version, alle geltenden Schritte mit Zustand, Notizen +- [x] Test: das PDF enthält Titel, Vorlage mit Version, alle geltenden Schritte mit Zustand, Notizen und Kommentare — geprüft am extrahierten Text, nicht am Vorhandensein der Datei -- [ ] Test: es enthält **kein** erzeugtes Passwort, über alle Schritt-Typen -- [ ] Test: ein Lauf eines fremden Kunden ist über die Adresse nicht abrufbar (wie #032 nach #036) -- [ ] Test: ohne Recht `401`, mit falschem Kunden `404` -- [ ] Test: fehlt Chromium, kommt eine Meldung mit Hinweis auf die Betriebsdoku — kein leeres PDF -- [ ] Test: zwei gleichzeitige Erzeugungen laufen, die dritte wartet oder wird abgewiesen (die +- [x] Test: es enthält **kein** erzeugtes Passwort, über alle Schritt-Typen +- [x] Test: ein Lauf eines fremden Kunden ist über die Adresse nicht abrufbar (wie #032 nach #036) +- [x] Test: ohne Recht `401`, mit falschem Kunden `404` +- [x] Test: fehlt Chromium, kommt eine Meldung mit Hinweis auf die Betriebsdoku — kein leeres PDF +- [x] Test: zwei gleichzeitige Erzeugungen laufen, die dritte wartet oder wird abgewiesen (die Obergrenze gilt) -- [ ] Ein Blatt ist ausgedruckt und gegengesehen worden — das ist eine Abnahme von Hand und steht +- [x] Ein Blatt ist ausgedruckt und gegengesehen worden — das ist eine Abnahme von Hand und steht als solche im Devlog diff --git a/docs/issues/README.md b/docs/issues/README.md index e94d2ad..763a8d9 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -45,6 +45,6 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac | #038 | [Die E2E-Abläufe laufen in keinem Tor und waren monatelang rot](038-e2e-im-tor.md) | erledigt | v0.2.0 | 3 h | 1 h 35 min | | #039 | [M23 – Bedingte Schritte und Abschnitte](039-bedingte-schritte.md) | erledigt | v0.3.0 | 12 h | 5 h 20 min | | #040 | [M24 – Vier-Augen-Freigabe](040-vier-augen-freigabe.md) | erledigt | v0.3.0 | 8 h | 3 h 30 min | -| #041 | [M25 – PDF-Übergabeprotokoll mit Unterschrift](041-pdf-uebergabeprotokoll.md) | offen | v0.3.0 | 8 h | – | +| #041 | [M25 – PDF-Übergabeprotokoll mit Unterschrift](041-pdf-uebergabeprotokoll.md) | erledigt | v0.3.0 | 8 h | 4 h 10 min | | #042 | [M26 – Weitere Schritt-Typen](042-weitere-schritt-typen.md) | offen | v0.3.0 | 6 h | – | -| | **Summe** | | | **272 h** | **82 h 45 min** | +| | **Summe** | | | **272 h** | **86 h 55 min** | diff --git a/package.json b/package.json index 7959b5f..6305608 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "nodemailer": "10.0.10", "openid-client": "^6.8.8", "pg": "^8.23.0", + "playwright-core": "1.63.0", "qrcode": "^1.5.4", "radix-ui": "^1.6.7", "react": "19.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d603b55..7194d90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -229,6 +229,9 @@ importers: pg: specifier: ^8.23.0 version: 8.23.0 + playwright-core: + specifier: 1.63.0 + version: 1.63.0 qrcode: specifier: ^1.5.4 version: 1.5.4 diff --git a/src/app/(app)/archiv/page.tsx b/src/app/(app)/archiv/page.tsx index 3aff5b5..e35a5f9 100644 --- a/src/app/(app)/archiv/page.tsx +++ b/src/app/(app)/archiv/page.tsx @@ -3,6 +3,7 @@ import { ArchivFilter, type ArchivFilterfelder } from "@/components/archiv/archi import { ArchivTreffer } from "@/components/archiv/archiv-treffer"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { kundenEbeneSichtbar } from "@/server/mandant"; +import { pdfVerfuegbar } from "@/server/pdf"; import { ARCHIV_HOECHSTENS, SUCHE_MINDESTZEICHEN, @@ -71,10 +72,12 @@ export default async function ArchivSeite({ ...(felder.laufende ? { auchLaufende: true } : {}), }; - const [ergebnis, werte, kundenEbene] = await Promise.all([ + const [ergebnis, werte, kundenEbene, mitPdf] = await Promise.all([ archivSuche(benutzer, filter), archivFilterwerte(benutzer), kundenEbeneSichtbar(), + // Einmal fuer die ganze Liste, nicht je Zeile: es ist derselbe Server (#041). + pdfVerfuegbar(), ]); // Hervorgehoben wird nur, wonach auch gesucht wurde: ein verworfener Begriff // hat keine Fundstellen und darf keine markieren. @@ -117,7 +120,13 @@ export default async function ArchivSeite({

diff --git a/src/app/(app)/laeufe/[id]/page.tsx b/src/app/(app)/laeufe/[id]/page.tsx index 70c5f48..93fbe0e 100644 --- a/src/app/(app)/laeufe/[id]/page.tsx +++ b/src/app/(app)/laeufe/[id]/page.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { notFound } from "next/navigation"; -import { ArrowLeft, MessageSquare, Printer, ScrollText, Star } from "lucide-react"; +import { ArrowLeft, FileDown, MessageSquare, Printer, ScrollText, Star } from "lucide-react"; import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums"; import { Markdown } from "@/components/markdown"; import { AbschnittHuelle } from "@/components/laeufe/abschnitt-huelle"; @@ -21,6 +21,7 @@ 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 { pdfVerfuegbar } from "@/server/pdf"; import { anhaengeDesLaufs } from "@/server/queries/anhaenge"; import { protokollDesLaufs } from "@/server/queries/protokoll"; import { @@ -324,6 +325,12 @@ export default async function LaufSeite({ // Die Sicht hat `laufHolen` oben schon angewandt; hier nur die Metadaten der // Anhaenge dieses Laufs (#032). const anhaenge = await anhaengeDesLaufs(id, benutzer); + /* + * Ein `access()` auf drei Pfade, nicht mehr — aber es entscheidet, ob der + * PDF-Knopf ueberhaupt erscheint. Ohne Chromium gaebe er zuverlaessig eine + * Fehlermeldung aus, und daneben steht „Drucken“, das immer tut (#041). + */ + const mitPdf = await pdfVerfuegbar(); /* * Die Personenliste nur fuer den Onboarding-Fall holen — sonst stuende in * jedem Seitenaufbau eine Abfrage, die niemand braucht. @@ -440,6 +447,25 @@ export default async function LaufSeite({