fix(PROJ-81): BUG-81-3 - PDF-Vorschau via pdf.js statt sandboxed iframe
Chromium/Brave zeigte leeren PDF-Bereich (PDFium/MimeHandlerView verweigert Rendering in sandbox="allow-scripts" ohne allow-same-origin, Firefox war nicht betroffen). allow-same-origin nachzurüsten kam nicht infrage: Blob-URLs erben den App-Origin, die Kombination hätte die Sandbox faktisch aufgehoben (PROJ-61-Kontext). Neu: PdfCanvasPreview.tsx rendert PDF via pdf.js selbst nach <canvas>, kein iframe/eingebettetes Dokument mehr nötig. Parsing im Worker, kein PDF-eigenes JavaScript, kein Text-/Annotationslayer im DOM. Seitenlimit 30, eigener Fehlerzweig für passwortgeschützte/beschädigte PDFs. Neue Dependency pdfjs-dist@6.2.108 - Restrisiko: PDF-Parsing läuft jetzt im App-Origin statt im Browser-Viewer-Prozess. Noch nicht im Browser verifiziert, Status bleibt In Review. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WapWkrQusDuBMhaN8WyuXB
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
ca693ded1b
commit
1ad1132006
@@ -77,7 +77,7 @@ Mail-Ansicht (/mail/[id])
|
|||||||
|
|
||||||
### D) Abhängigkeiten (Pakete)
|
### D) Abhängigkeiten (Pakete)
|
||||||
|
|
||||||
- PDF-Viewer-Bibliothek im Frontend (Anzeige im Dialog, ohne Download). Bilder benötigen keine zusätzliche Bibliothek (natives `<img>`).
|
- PDF-Viewer-Bibliothek im Frontend (Anzeige im Dialog, ohne Download): **`pdfjs-dist@6.2.108`**, ergänzt im dritten Fix-Durchgang (BUG-81-3). Der zunächst genutzte browsereigene Viewer im iframe war nicht browserübergreifend nutzbar. Bilder benötigen keine zusätzliche Bibliothek (natives `<img>`).
|
||||||
- Keine neuen Server-/Systemabhängigkeiten.
|
- Keine neuen Server-/Systemabhängigkeiten.
|
||||||
|
|
||||||
## Implementation Notes (Frontend, 2026-08-06)
|
## Implementation Notes (Frontend, 2026-08-06)
|
||||||
@@ -252,20 +252,49 @@ Kein Code-Fix. AC1 oben umformuliert: statt "Klick auf Anhang öffnet Vorschau"
|
|||||||
**Das Security-Modell bleibt unverändert:** der Server-Wert entscheidet ausschließlich, *welcher* Eintrag aus der festen Whitelist gewählt wird. Gerendert wird nach wie vor nur mit dem clientseitig erzwungenen MIME-Typ aus dieser Liste. Ein Wert außerhalb der Whitelist — insbesondere `image/svg+xml` (PROJ-61-Vektor) oder `text/html` — führt zu "keine Vorschau", nicht zum Rendern. SVG ist in `ALLOWED_MIME_TYPES` bewusst nicht enthalten.
|
**Das Security-Modell bleibt unverändert:** der Server-Wert entscheidet ausschließlich, *welcher* Eintrag aus der festen Whitelist gewählt wird. Gerendert wird nach wie vor nur mit dem clientseitig erzwungenen MIME-Typ aus dieser Liste. Ein Wert außerhalb der Whitelist — insbesondere `image/svg+xml` (PROJ-61-Vektor) oder `text/html` — führt zu "keine Vorschau", nicht zum Rendern. SVG ist in `ALLOWED_MIME_TYPES` bewusst nicht enthalten.
|
||||||
Ein Angreifer gewinnt dadurch nichts: er könnte über den Content-Type höchstens erreichen, dass seine endungslose Datei als PDF oder Bild *interpretiert* wird — genau die beiden Pfade, die ohnehin als sicher ausgelegt sind (erzwungener MIME-Typ, PDF im Sandbox-iframe ohne `allow-same-origin`).
|
Ein Angreifer gewinnt dadurch nichts: er könnte über den Content-Type höchstens erreichen, dass seine endungslose Datei als PDF oder Bild *interpretiert* wird — genau die beiden Pfade, die ohnehin als sicher ausgelegt sind (erzwungener MIME-Typ, PDF im Sandbox-iframe ohne `allow-same-origin`).
|
||||||
|
|
||||||
|
### BUG-81-3 (Medium) — PDF-Vorschau blank in Chromium/Brave — **fixed**
|
||||||
|
|
||||||
|
**Befund aus dem Live-Test auf 132:** Firefox rendert die PDF-Vorschau einwandfrei, Chromium/Brave zeigt eine leere weiße Fläche — kein Fehler, kein Download-Prompt. Ursache bestätigt: Chromiums PDFium läuft als MimeHandlerView-Extension und benötigt dafür Same-Origin-Zugriff; in einem iframe mit `sandbox="allow-scripts"` ohne `allow-same-origin` verweigert er sich still. Firefox' pdf.js-basierter Viewer kommt damit klar. Kein Spec-Verstoß, sondern Chromium-spezifisches Verhalten.
|
||||||
|
|
||||||
|
#### Bewertung Option 1 (`allow-same-origin` ergänzen) — **verworfen**
|
||||||
|
Die entscheidende Frage war, ob eine Blob-URL einen eigenen opaken Origin hat. **Hat sie nicht.** `URL.createObjectURL()` erzeugt eine URL der Form `blob:https://host/uuid`; der Origin des daraus geladenen Dokuments ist per Spezifikation der Origin des erzeugenden Kontexts, also unser App-Origin. Opak wird ein Frame nur durch die Sandbox selbst (`allow-same-origin` weggelassen) — nicht durch das Blob.
|
||||||
|
|
||||||
|
Daraus folgt: mit `sandbox="allow-scripts allow-same-origin"` auf same-origin-Inhalt wäre der Frame vollwertig App-Origin. Diese Kombination hebt die Sandbox praktisch auf, weil das eingebettete Dokument über `parent.document` an das Sandbox-Attribut selbst herankommt und sich per Re-Navigation entsandboxen kann; die HTML-Spezifikation warnt genau vor dieser Kombination. Der Frame hätte damit Zugriff auf DOM, nicht-httpOnly-Cookies, `localStorage` und credential-behaftete API-Aufrufe.
|
||||||
|
|
||||||
|
Man könnte einwenden, dass ein getarntes HTML-Dokument wegen des erzwungenen Blob-MIME-Typs (`application/pdf`) ohnehin nie als HTML geparst wird — das stimmt. Aber damit hinge die gesamte Absicherung an genau einer Kontrolle. Für ein Archiv mit fremdem, unkontrolliertem Mail-Inhalt und der PROJ-61-Vorgeschichte ist das die falsche Richtung.
|
||||||
|
|
||||||
|
#### Umgesetzt: Option 2 (pdf.js gebündelt)
|
||||||
|
Neue Komponente `src/components/mail/PdfCanvasPreview.tsx`, neue Dependency `pdfjs-dist@6.2.108`.
|
||||||
|
- PDF wird von pdf.js **selbst nach `<canvas>` gerendert** — kein iframe, kein eingebettetes Dokument, damit entfällt die Sandbox-Frage vollständig statt sie aufzuweichen.
|
||||||
|
- Verhalten ist in Chromium und Firefox identisch, weil nicht mehr vom Viewer des Browsers abhängig.
|
||||||
|
- Parsing läuft im Worker; das Worker-Bundle wird vom Build als statisches Asset emittiert (verifiziert: `.next/static/media/pdf.worker.min.*.mjs`).
|
||||||
|
- PDF-eigenes JavaScript wird nicht ausgeführt: das Scripting-Sandbox-Bundle (`pdf.sandbox`) wird nicht geladen. `isEvalSupported` gibt es in pdfjs-dist v6 nicht mehr, weil `eval()` dort bereits vollständig entfernt wurde.
|
||||||
|
- Es wird ausschließlich in Canvas gezeichnet, kein Text-/Annotationslayer — es gelangt also kein HTML aus dem PDF ins DOM.
|
||||||
|
- Seitenlimit `MAX_RENDERED_PAGES = 30` mit Hinweis, damit ein PDF mit sehr vielen Seiten den Tab nicht blockiert.
|
||||||
|
- Eigener Fehlerzweig für passwortgeschützte/beschädigte PDFs (Edge Case aus der Spec) mit Download-Hinweis; `doc.destroy()` beim Unmount.
|
||||||
|
|
||||||
|
Im Dialog hält jetzt `pdfBlob` das PDF, Bilder laufen unverändert über die Blob-URL (Bildpfad nicht angefasst, er funktioniert live). Der Guard gegen die BUG-81-1-Schleife nutzt jetzt `loadedRef` statt `urlRef`, damit er beide Pfade abdeckt.
|
||||||
|
|
||||||
|
**Restrisiko / ehrlich:** die Verlagerung des PDF-Parsings in unseren Origin heißt, dass eine Schwachstelle in pdf.js im App-Origin landet statt im Viewer-Prozess des Browsers. pdf.js ist genau für feindliche PDFs ausgelegt, parst im Worker und ohne `eval`, und die Alternative (Option 1) hätte den App-Origin ohnehin direkt preisgegeben. Die Dependency muss aber mitgepflegt werden — pdf.js ist ein regelmäßiges CVE-Ziel.
|
||||||
|
|
||||||
### Offen, wie abgestimmt (kein Code-Fix in diesem Durchgang)
|
### Offen, wie abgestimmt (kein Code-Fix in diesem Durchgang)
|
||||||
- **BUG-81-3** — PDF-Sandbox in Firefox/Safari unbelegt: nach Deploy im Browser verifizieren. Fällt das Rendering aus, braucht es einen sichtbaren Hinweis statt eines leeren weißen Rahmens.
|
|
||||||
- **Audit-Logging für Anhang-Abrufe** (Bestandsfund aus dem Security-Audit): bewusst **nicht** hier mitgefixt, bekommt ein eigenes Ticket.
|
- **Audit-Logging für Anhang-Abrufe** (Bestandsfund aus dem Security-Audit): bewusst **nicht** hier mitgefixt, bekommt ein eigenes Ticket.
|
||||||
- Die drei Backend-Fragen sind vom QA-Lauf beantwortet (nosniff nur über nginx, `Content-Disposition: attachment` gesetzt, **keine** serverseitige Content-Type-Whitelist). Für die Vorschau unkritisch, weil der Dialog den Server-Content-Type ignoriert; die empfohlene Handler-seitige Härtung ist Backend-Scope.
|
- Die drei Backend-Fragen sind vom QA-Lauf beantwortet (nosniff nur über nginx, `Content-Disposition: attachment` gesetzt, **keine** serverseitige Content-Type-Whitelist). Für die Vorschau unkritisch, weil der Dialog den Server-Content-Type ignoriert; die empfohlene Handler-seitige Härtung ist Backend-Scope.
|
||||||
|
|
||||||
Nach den Fixes: `npx tsc --noEmit` und `npm run build` fehlerfrei.
|
Nach den Fixes: `npx tsc --noEmit` und `npm run build` fehlerfrei.
|
||||||
|
|
||||||
### Stand nach zweitem Fix-Durchgang
|
### Stand nach drittem Fix-Durchgang
|
||||||
Alle im QA-Bericht gemeldeten Bugs sind erledigt: BUG-81-1 (High/Blocker), BUG-81-2 (Medium), BUG-81-4 (Low), BUG-81-5 (Low, Spec-Text), BUG-81-6 (Low). Offen bleibt allein **BUG-81-3** — die PDF-Darstellung im Sandbox-iframe ist für Firefox und Safari unbelegt und lässt sich nur im echten Browser gegen eine laufende Instanz prüfen.
|
Alle im QA-Bericht gemeldeten Bugs sind erledigt: BUG-81-1 (High/Blocker), BUG-81-2 (Medium), BUG-81-3 (Medium), BUG-81-4, BUG-81-5, BUG-81-6 (Low).
|
||||||
|
|
||||||
**Deployment-Empfehlung: deploybar auf 132.** Kein offener Blocker im Code. Der Status bleibt bewusst auf **In Review** und geht erst auf Deployed, wenn nach dem Deploy verifiziert ist:
|
**Deployment-Empfehlung: erneut auf 132 deployen und nachtesten.** Der Status bleibt auf **In Review**, weil der PDF-Pfad komplett ausgetauscht wurde und der Fix bisher nur durch Build und statische Analyse belegt ist, nicht im Browser. Nach dem Deploy zu verifizieren:
|
||||||
- BUG-81-3: PDF-Vorschau in Chrome, Firefox, Safari, Edge. Rendert ein Browser nichts, braucht es einen sichtbaren Hinweis statt eines leeren weißen Rahmens.
|
- **PDF-Vorschau in Chromium/Brave** — das war das ursprüngliche Symptom, hier muss jetzt gerendert werden.
|
||||||
|
- **PDF-Vorschau in Firefox** — Regressionsprobe, dort funktionierte es vorher schon.
|
||||||
|
- Mehrseitiges PDF: Seiten scrollbar, ab 30 Seiten erscheint der Kürzungshinweis.
|
||||||
|
- Passwortgeschütztes/beschädigtes PDF: sichtbarer Fehlertext statt leerer Fläche.
|
||||||
|
- Bild-Vorschau (JPG) unverändert funktionsfähig — der Pfad wurde bewusst nicht angefasst.
|
||||||
- Gegenprobe zu BUG-81-1: fehlgeschlagene Vorschau (z.B. 404) erzeugt genau **einen** Request, keine Schleife — im Netzwerk-Tab prüfen.
|
- Gegenprobe zu BUG-81-1: fehlgeschlagene Vorschau (z.B. 404) erzeugt genau **einen** Request, keine Schleife — im Netzwerk-Tab prüfen.
|
||||||
- Gegenprobe zu BUG-81-6: endungsloser PDF-Anhang bekommt einen Vorschau-Button und rendert.
|
- Gegenprobe zu BUG-81-6: endungsloser PDF-Anhang bekommt einen Vorschau-Button und rendert.
|
||||||
|
- Ladezeit gegen das Technical Requirement (< 2 s bei normaler Dateigröße) gegenprüfen — pdf.js rendert langsamer als der native Viewer.
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
_To be added by /deploy_
|
_To be added by /deploy_
|
||||||
|
|||||||
Generated
+263
@@ -35,6 +35,7 @@
|
|||||||
"lucide-react": "^1.28.0",
|
"lucide-react": "^1.28.0",
|
||||||
"next": "^16.1.1",
|
"next": "^16.1.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"pdfjs-dist": "^6.2.108",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-hook-form": "^7.71.1",
|
"react-hook-form": "^7.71.1",
|
||||||
@@ -1198,6 +1199,256 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@napi-rs/canvas": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-OlI657a5XXvKGFX7kNeIzJ8rO7IXt87Mqu2H8rXE46viAuOfum/JA7ysX7+eBhxNKznT+RCZh418mndlcFX3+w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"workspaces": [
|
||||||
|
"e2e/*"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/canvas-android-arm64": "1.0.3",
|
||||||
|
"@napi-rs/canvas-darwin-arm64": "1.0.3",
|
||||||
|
"@napi-rs/canvas-darwin-x64": "1.0.3",
|
||||||
|
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.3",
|
||||||
|
"@napi-rs/canvas-linux-arm64-gnu": "1.0.3",
|
||||||
|
"@napi-rs/canvas-linux-arm64-musl": "1.0.3",
|
||||||
|
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.3",
|
||||||
|
"@napi-rs/canvas-linux-x64-gnu": "1.0.3",
|
||||||
|
"@napi-rs/canvas-linux-x64-musl": "1.0.3",
|
||||||
|
"@napi-rs/canvas-win32-arm64-msvc": "1.0.3",
|
||||||
|
"@napi-rs/canvas-win32-x64-msvc": "1.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-7kSCdUhoXiO+AaIMXdBGdtp6EctZNkmF62Rea/BmVQlwKaM3bBhOzyGUzxyxz9dv5vdBfpyAaxhSRSJF4kqK4A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-ds14V1BPagLszQyaDTeggny5fNeTCqsUQ5QhFj9VDxSEfzrVxXtdbR0LoFyKa0Siaaw8KvqSk4t7k/WoZJwvbg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-qof3LRAAycmkV2I1izZo9RoSHF8kCQr5O05sFwv0jK8rSdYV6KHVwimo6Qb7RxZj40WHKbLHm5JDaUF0o5XUAA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-FU2kKZLmolHA9+KcUA+l1+xH3WTLUUTQDU/kLv9SEUr2TrRPu94aytOeizFJDHPs/QBcw4QL1mCQhetQXYBbag==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-GVSjntxKeA+/y/ZKf1F+cmUw1WeIkE5aMRPqnZUlBTBvBcrvgWccJAWuYCKPX4QJQwZILIIwhgdAbl51yj6fpA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-J51oK/axyZ13kxycumSMfLiDZMdWdOVvqDFI28BpuViZHE3A0bQfr8B5vg8YnPEnqLD3BSn1hkdlh2buspEcNQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-CtQgQjoVTX67jS9XuCTtJ40Sl7wRLMguoFnnGnfDmCWf7kzKFZVwj5ynqUOIGKFMSB61ZCuQlwPvVNxYTTseaw==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-jtfzAHFp+FRaR7zGT4jyCe6wUgAG/dVb5A4Apd8FY9jKarntDfUAlJXscugiH7ZF5kKnu7/lHFk9LaDPcrGEVQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-xTzaUCKUHTY4bCGadeeRZggbRVbGUT1petg7Z8r9AJR2+D9Bqu6nQAgqBGC6D47tA70LjaaaLTrJ7wNY1T74dg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-ktVLuBkI6QVOm5BwO/WbdGwxgeetAMJa7TTmR8qBarXF0OU2NKjvjUtPJAl2y8t+zBRczJl/1VOl9gua6WcK2g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-SGhlQ8bDjL1Cz2KnsKMasr/5sTcwG/SZkB6WCJxLsmSm/3aS2C+3p39bA7iZ2/94+NkVDySZfbiGoaSZSFHYxA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz",
|
||||||
@@ -7869,6 +8120,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pdfjs-dist": {
|
||||||
|
"version": "6.2.108",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.2.108.tgz",
|
||||||
|
"integrity": "sha512-YxFb+SQcodN2rnX9Tn3dHYlqfb7NjlzzfONPpJd+AKoKtUjEdevTfbC07d5TcczzOK6261auRkP/M8OBHs9vFQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.13.0 || >=24"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/canvas": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
"lucide-react": "^1.28.0",
|
"lucide-react": "^1.28.0",
|
||||||
"next": "^16.1.1",
|
"next": "^16.1.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"pdfjs-dist": "^6.2.108",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-hook-form": "^7.71.1",
|
"react-hook-form": "^7.71.1",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import { Download, Loader2 } from "lucide-react";
|
import { Download, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
import { downloadMailAttachment, type MailAttachment } from "@/lib/api";
|
import { downloadMailAttachment, type MailAttachment } from "@/lib/api";
|
||||||
|
import { PdfCanvasPreview } from "@/components/mail/PdfCanvasPreview";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import {
|
import {
|
||||||
@@ -27,10 +28,10 @@ import {
|
|||||||
* - SVG ist absichtlich NICHT in der Whitelist (SVG kann Skripte enthalten).
|
* - SVG ist absichtlich NICHT in der Whitelist (SVG kann Skripte enthalten).
|
||||||
* - Der Server-Content-Type wird nur bei Anhaengen OHNE Dateiendung als Hinweis
|
* - Der Server-Content-Type wird nur bei Anhaengen OHNE Dateiendung als Hinweis
|
||||||
* zur Auswahl aus derselben Whitelist genutzt (BUG-81-6), nie zum Rendern.
|
* zur Auswahl aus derselben Whitelist genutzt (BUG-81-6), nie zum Rendern.
|
||||||
* - PDFs laufen in einem sandboxed iframe MIT allow-scripts, aber ohne
|
* - PDFs werden gar nicht mehr in einem iframe angezeigt, sondern von pdf.js
|
||||||
* allow-same-origin: der Frame hat dadurch einen eigenen, undurchsichtigen
|
* nach Canvas gerendert (siehe PdfCanvasPreview, BUG-81-3). Damit entfaellt
|
||||||
* Origin und keinen Zugriff auf Cookies/DOM der App. allow-scripts ist noetig,
|
* die Frage nach dem Sandbox-Attribut komplett: es gibt kein eingebettetes
|
||||||
* weil die browsereigenen PDF-Viewer sonst nicht rendern.
|
* Dokument, das Rechte erben koennte.
|
||||||
* - Es entsteht kein oeffentlicher Link: die Blob-URL lebt nur im Tab und wird
|
* - Es entsteht kein oeffentlicher Link: die Blob-URL lebt nur im Tab und wird
|
||||||
* beim Schliessen des Dialogs wieder freigegeben.
|
* beim Schliessen des Dialogs wieder freigegeben.
|
||||||
*/
|
*/
|
||||||
@@ -146,18 +147,23 @@ export function AttachmentPreviewDialog({
|
|||||||
const isLarge = attachment.size > PREVIEW_SIZE_WARN_BYTES;
|
const isLarge = attachment.size > PREVIEW_SIZE_WARN_BYTES;
|
||||||
|
|
||||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||||
|
// Nur fuer den PDF-Pfad: pdf.js rendert aus dem Blob, nicht aus einer URL.
|
||||||
|
const [pdfBlob, setPdfBlob] = useState<Blob | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [confirmedLarge, setConfirmedLarge] = useState(false);
|
const [confirmedLarge, setConfirmedLarge] = useState(false);
|
||||||
|
|
||||||
// Blob-URL zuverlaessig freigeben, auch bei Unmount waehrend des Ladens.
|
// Blob-URL zuverlaessig freigeben, auch bei Unmount waehrend des Ladens.
|
||||||
const urlRef = useRef<string | null>(null);
|
const urlRef = useRef<string | null>(null);
|
||||||
|
const loadedRef = useRef(false);
|
||||||
const revoke = useCallback(() => {
|
const revoke = useCallback(() => {
|
||||||
if (urlRef.current) {
|
if (urlRef.current) {
|
||||||
URL.revokeObjectURL(urlRef.current);
|
URL.revokeObjectURL(urlRef.current);
|
||||||
urlRef.current = null;
|
urlRef.current = null;
|
||||||
}
|
}
|
||||||
|
loadedRef.current = false;
|
||||||
setObjectUrl(null);
|
setObjectUrl(null);
|
||||||
|
setPdfBlob(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -167,9 +173,15 @@ export function AttachmentPreviewDialog({
|
|||||||
const { blob } = await downloadMailAttachment(mailId, attachment.index);
|
const { blob } = await downloadMailAttachment(mailId, attachment.index);
|
||||||
// Erzwungener MIME-Typ statt Server-Angabe (siehe Security-Hinweis oben).
|
// Erzwungener MIME-Typ statt Server-Angabe (siehe Security-Hinweis oben).
|
||||||
const safeBlob = new Blob([blob], { type: forcedMimeType(attachment) });
|
const safeBlob = new Blob([blob], { type: forcedMimeType(attachment) });
|
||||||
const url = URL.createObjectURL(safeBlob);
|
loadedRef.current = true;
|
||||||
urlRef.current = url;
|
if (safeBlob.type === "application/pdf") {
|
||||||
setObjectUrl(url);
|
// PDF: kein iframe, keine Blob-URL — pdf.js rendert direkt nach Canvas.
|
||||||
|
setPdfBlob(safeBlob);
|
||||||
|
} else {
|
||||||
|
const url = URL.createObjectURL(safeBlob);
|
||||||
|
urlRef.current = url;
|
||||||
|
setObjectUrl(url);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(
|
setError(
|
||||||
e instanceof Error
|
e instanceof Error
|
||||||
@@ -194,7 +206,8 @@ export function AttachmentPreviewDialog({
|
|||||||
if (kind === "unsupported") return;
|
if (kind === "unsupported") return;
|
||||||
if (isLarge && !confirmedLarge) return;
|
if (isLarge && !confirmedLarge) return;
|
||||||
if (error) return;
|
if (error) return;
|
||||||
if (urlRef.current || loading) return;
|
// loadedRef deckt beide Pfade ab (Blob-URL fuer Bilder, Blob fuer PDF).
|
||||||
|
if (loadedRef.current || loading) return;
|
||||||
void load();
|
void load();
|
||||||
}, [open, kind, isLarge, confirmedLarge, error, loading, load]);
|
}, [open, kind, isLarge, confirmedLarge, error, loading, load]);
|
||||||
|
|
||||||
@@ -283,17 +296,16 @@ export function AttachmentPreviewDialog({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
allow-scripts ohne allow-same-origin: der Frame laeuft in einem
|
BUG-81-3: kein iframe mehr fuer PDF. Chromiums PDFium-Viewer
|
||||||
eigenen, undurchsichtigen Origin (kein Zugriff auf Cookies/DOM der
|
rendert in einem sandboxed iframe ohne allow-same-origin still
|
||||||
App). Skripte sind noetig, weil die eingebauten PDF-Viewer der
|
gar nichts (leere weisse Flaeche), Firefox schon. pdf.js rendert
|
||||||
Browser sonst nicht rendern.
|
stattdessen selbst nach Canvas — browserunabhaengig und ohne die
|
||||||
|
Sandbox aufweichen zu muessen. Siehe PdfCanvasPreview.
|
||||||
*/}
|
*/}
|
||||||
{!loading && !error && objectUrl && kind === "pdf" && (
|
{!loading && !error && pdfBlob && kind === "pdf" && (
|
||||||
<iframe
|
<PdfCanvasPreview
|
||||||
src={objectUrl}
|
blob={pdfBlob}
|
||||||
title={`Vorschau: ${attachment.filename}`}
|
filename={attachment.filename}
|
||||||
sandbox="allow-scripts"
|
|
||||||
className="h-[70vh] w-full border-0 bg-white"
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PROJ-81 / BUG-81-3: PDF-Vorschau via pdf.js (pdfjs-dist).
|
||||||
|
*
|
||||||
|
* Warum nicht der browsereigene Viewer im iframe:
|
||||||
|
* Chromiums PDFium laeuft als MimeHandlerView-Extension und braucht dafuer
|
||||||
|
* Same-Origin-Zugriff. In einem iframe mit sandbox="allow-scripts" OHNE
|
||||||
|
* allow-same-origin verweigert er sich still — genau das beobachtete Symptom
|
||||||
|
* (leere weisse Flaeche, kein Fehler). allow-same-origin nachzuruesten waere
|
||||||
|
* der falsche Weg: eine Blob-URL erbt den Origin der erzeugenden Seite, mit
|
||||||
|
* allow-same-origin waere der Frame also NICHT isoliert, sondern volles
|
||||||
|
* App-Origin (inkl. Zugriff auf parent.document und damit Aufhebung der
|
||||||
|
* eigenen Sandbox). Siehe Feature-Spec PROJ-81, Abschnitt BUG-81-3.
|
||||||
|
*
|
||||||
|
* pdf.js rendert stattdessen selbst nach <canvas>. Kein iframe, kein
|
||||||
|
* Fremd-Origin, keine Abhaengigkeit vom Sandbox-Verhalten des Browsers,
|
||||||
|
* identisches Verhalten in Chromium und Firefox.
|
||||||
|
*
|
||||||
|
* Haertung:
|
||||||
|
* - PDF-eigenes JavaScript wird nicht ausgefuehrt: das Scripting-Sandbox-Bundle
|
||||||
|
* (pdf.sandbox) wird gar nicht erst geladen, und `getDocument` bekommt keine
|
||||||
|
* Scripting-Option. pdfjs-dist v6 kennt `isEvalSupported` nicht mehr, weil
|
||||||
|
* eval() dort bereits vollstaendig entfernt wurde.
|
||||||
|
* - Das Parsen laeuft im Worker, nicht im Haupt-Thread.
|
||||||
|
* - Es wird ausschliesslich in Canvas gezeichnet, kein HTML aus dem PDF ins DOM
|
||||||
|
* (keine Textlayer-/Annotationslayer-Nutzung).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Obergrenze, damit ein PDF mit sehr vielen Seiten den Tab nicht blockiert. */
|
||||||
|
const MAX_RENDERED_PAGES = 30;
|
||||||
|
|
||||||
|
export interface PdfCanvasPreviewProps {
|
||||||
|
/** Bereits geladener Anhang mit erzwungenem MIME-Typ application/pdf. */
|
||||||
|
blob: Blob;
|
||||||
|
filename: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PdfCanvasPreview({ blob, filename }: PdfCanvasPreviewProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [rendering, setRendering] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [truncatedAt, setTruncatedAt] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let doc: any = null;
|
||||||
|
|
||||||
|
async function render() {
|
||||||
|
setRendering(true);
|
||||||
|
setError(null);
|
||||||
|
setTruncatedAt(null);
|
||||||
|
try {
|
||||||
|
const pdfjs = await import("pdfjs-dist");
|
||||||
|
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||||
|
"pdfjs-dist/build/pdf.worker.min.mjs",
|
||||||
|
import.meta.url
|
||||||
|
).toString();
|
||||||
|
|
||||||
|
const data = await blob.arrayBuffer();
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
doc = await pdfjs.getDocument({ data }).promise;
|
||||||
|
if (cancelled || !container) return;
|
||||||
|
|
||||||
|
container.replaceChildren();
|
||||||
|
|
||||||
|
const total = doc.numPages as number;
|
||||||
|
const limit = Math.min(total, MAX_RENDERED_PAGES);
|
||||||
|
|
||||||
|
for (let pageNo = 1; pageNo <= limit; pageNo++) {
|
||||||
|
const page = await doc.getPage(pageNo);
|
||||||
|
if (cancelled || !container) return;
|
||||||
|
|
||||||
|
const unscaled = page.getViewport({ scale: 1 });
|
||||||
|
const available = container.clientWidth || 800;
|
||||||
|
// Auf Containerbreite skalieren, aber nicht groesser als noetig.
|
||||||
|
const scale = Math.min(available / unscaled.width, 2);
|
||||||
|
const viewport = page.getViewport({ scale });
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const ratio = window.devicePixelRatio || 1;
|
||||||
|
canvas.width = Math.floor(viewport.width * ratio);
|
||||||
|
canvas.height = Math.floor(viewport.height * ratio);
|
||||||
|
// Breite/Hoehe des Bitmaps ergeben sich aus dem PDF, die Darstellung
|
||||||
|
// regelt Tailwind (w-full h-auto) — keine inline styles.
|
||||||
|
canvas.className = "mb-3 w-full h-auto shadow-sm";
|
||||||
|
canvas.setAttribute("role", "img");
|
||||||
|
canvas.setAttribute("aria-label", `${filename}, Seite ${pageNo}`);
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) throw new Error("Canvas wird von diesem Browser nicht unterstützt.");
|
||||||
|
ctx.scale(ratio, ratio);
|
||||||
|
|
||||||
|
container.appendChild(canvas);
|
||||||
|
await page.render({ canvas, canvasContext: ctx, viewport }).promise;
|
||||||
|
page.cleanup();
|
||||||
|
if (cancelled) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (total > limit) setTruncatedAt(limit);
|
||||||
|
} catch (e) {
|
||||||
|
if (cancelled) return;
|
||||||
|
setError(
|
||||||
|
e instanceof Error
|
||||||
|
? e.message
|
||||||
|
: "PDF konnte nicht dargestellt werden."
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setRendering(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void render();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
// Worker-/Speicherressourcen des Dokuments freigeben.
|
||||||
|
void doc?.destroy?.();
|
||||||
|
container?.replaceChildren();
|
||||||
|
};
|
||||||
|
}, [blob, filename]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-[70vh] overflow-auto bg-neutral-100 p-3">
|
||||||
|
{rendering && (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-8 text-sm text-neutral-700">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
PDF wird gerendert…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="px-2 py-6 text-center text-sm text-neutral-800">
|
||||||
|
<p className="font-medium">PDF konnte nicht angezeigt werden.</p>
|
||||||
|
<p className="mt-1">{error}</p>
|
||||||
|
<p className="mt-1">
|
||||||
|
Möglich bei passwortgeschützten oder beschädigten Dateien. Die Datei
|
||||||
|
kann weiterhin heruntergeladen werden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={containerRef} />
|
||||||
|
|
||||||
|
{truncatedAt !== null && (
|
||||||
|
<p className="px-2 py-3 text-center text-sm text-neutral-700">
|
||||||
|
Vorschau auf die ersten {truncatedAt} Seiten begrenzt. Für das
|
||||||
|
vollständige Dokument bitte herunterladen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user