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
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
|
||||
import { downloadMailAttachment, type MailAttachment } from "@/lib/api";
|
||||
import { PdfCanvasPreview } from "@/components/mail/PdfCanvasPreview";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
@@ -27,10 +28,10 @@ import {
|
||||
* - SVG ist absichtlich NICHT in der Whitelist (SVG kann Skripte enthalten).
|
||||
* - Der Server-Content-Type wird nur bei Anhaengen OHNE Dateiendung als Hinweis
|
||||
* zur Auswahl aus derselben Whitelist genutzt (BUG-81-6), nie zum Rendern.
|
||||
* - PDFs laufen in einem sandboxed iframe MIT allow-scripts, aber ohne
|
||||
* allow-same-origin: der Frame hat dadurch einen eigenen, undurchsichtigen
|
||||
* Origin und keinen Zugriff auf Cookies/DOM der App. allow-scripts ist noetig,
|
||||
* weil die browsereigenen PDF-Viewer sonst nicht rendern.
|
||||
* - PDFs werden gar nicht mehr in einem iframe angezeigt, sondern von pdf.js
|
||||
* nach Canvas gerendert (siehe PdfCanvasPreview, BUG-81-3). Damit entfaellt
|
||||
* die Frage nach dem Sandbox-Attribut komplett: es gibt kein eingebettetes
|
||||
* Dokument, das Rechte erben koennte.
|
||||
* - Es entsteht kein oeffentlicher Link: die Blob-URL lebt nur im Tab und wird
|
||||
* beim Schliessen des Dialogs wieder freigegeben.
|
||||
*/
|
||||
@@ -146,18 +147,23 @@ export function AttachmentPreviewDialog({
|
||||
const isLarge = attachment.size > PREVIEW_SIZE_WARN_BYTES;
|
||||
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
const [confirmedLarge, setConfirmedLarge] = useState(false);
|
||||
|
||||
// Blob-URL zuverlaessig freigeben, auch bei Unmount waehrend des Ladens.
|
||||
const urlRef = useRef<string | null>(null);
|
||||
const loadedRef = useRef(false);
|
||||
const revoke = useCallback(() => {
|
||||
if (urlRef.current) {
|
||||
URL.revokeObjectURL(urlRef.current);
|
||||
urlRef.current = null;
|
||||
}
|
||||
loadedRef.current = false;
|
||||
setObjectUrl(null);
|
||||
setPdfBlob(null);
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -167,9 +173,15 @@ export function AttachmentPreviewDialog({
|
||||
const { blob } = await downloadMailAttachment(mailId, attachment.index);
|
||||
// Erzwungener MIME-Typ statt Server-Angabe (siehe Security-Hinweis oben).
|
||||
const safeBlob = new Blob([blob], { type: forcedMimeType(attachment) });
|
||||
const url = URL.createObjectURL(safeBlob);
|
||||
urlRef.current = url;
|
||||
setObjectUrl(url);
|
||||
loadedRef.current = true;
|
||||
if (safeBlob.type === "application/pdf") {
|
||||
// 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) {
|
||||
setError(
|
||||
e instanceof Error
|
||||
@@ -194,7 +206,8 @@ export function AttachmentPreviewDialog({
|
||||
if (kind === "unsupported") return;
|
||||
if (isLarge && !confirmedLarge) 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();
|
||||
}, [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
|
||||
eigenen, undurchsichtigen Origin (kein Zugriff auf Cookies/DOM der
|
||||
App). Skripte sind noetig, weil die eingebauten PDF-Viewer der
|
||||
Browser sonst nicht rendern.
|
||||
BUG-81-3: kein iframe mehr fuer PDF. Chromiums PDFium-Viewer
|
||||
rendert in einem sandboxed iframe ohne allow-same-origin still
|
||||
gar nichts (leere weisse Flaeche), Firefox schon. pdf.js rendert
|
||||
stattdessen selbst nach Canvas — browserunabhaengig und ohne die
|
||||
Sandbox aufweichen zu muessen. Siehe PdfCanvasPreview.
|
||||
*/}
|
||||
{!loading && !error && objectUrl && kind === "pdf" && (
|
||||
<iframe
|
||||
src={objectUrl}
|
||||
title={`Vorschau: ${attachment.filename}`}
|
||||
sandbox="allow-scripts"
|
||||
className="h-[70vh] w-full border-0 bg-white"
|
||||
{!loading && !error && pdfBlob && kind === "pdf" && (
|
||||
<PdfCanvasPreview
|
||||
blob={pdfBlob}
|
||||
filename={attachment.filename}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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