fix: pass PDF bytes to pdf.js instead of fetching the blob: URL, which CSP connect-src blocks #871

This commit is contained in:
Linus Rath
2026-08-20 13:57:19 +02:00
parent 3c299baa3e
commit 434005b170
2 changed files with 21 additions and 5 deletions
+9 -2
View File
@@ -155,6 +155,11 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
// (text/html, image/svg+xml, ...) in a new tab would execute it in-origin.
const [canOpenInNewTab, setCanOpenInNewTab] = useState(false);
const [emlContent, setEmlContent] = useState<ParsedEml | null>(null);
// The PDF blob itself, kept alongside its object URL: the pdf.js mobile
// viewer needs the bytes, because fetching the blob: URL is blocked by CSP
// connect-src (#871). The URL is still what the <iframe> and the
// open-in-new-tab fallback use.
const [pdfBlob, setPdfBlob] = useState<Blob | null>(null);
// Decide whether to render the PDF in a plain <iframe> (desktop) or with the
// pdf.js canvas viewer (mobile). navigator.pdfViewerEnabled is the standard
@@ -208,6 +213,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
setObjectUrl(null);
setCanOpenInNewTab(false);
setEmlContent(null);
setPdfBlob(null);
setLoading(true);
setError(false);
setResolvedFileType(getFilePreviewKind(name));
@@ -253,6 +259,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
if (!cancelled) {
setObjectUrl(revokeUrl);
setCanOpenInNewTab(isMimeTypeSafeForInlinePreview(effectiveType));
if (previewType === "pdf") setPdfBlob(typedBlob);
}
}
} catch {
@@ -374,7 +381,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
/>
)}
{!loading && !error && fileType === "pdf" && objectUrl && !pdfInlineSupported && (
{!loading && !error && fileType === "pdf" && objectUrl && pdfBlob && !pdfInlineSupported && (
// Mobile browsers can't show a PDF inline in an <iframe> (Android: a
// blank frame / silent download; iOS: only the first page), so render
// it with pdf.js (canvas) instead. The header's open-in-new-tab /
@@ -383,7 +390,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
className="w-full max-w-3xl h-full overflow-auto rounded-lg bg-neutral-200 dark:bg-neutral-800 p-2"
onClick={(e) => e.stopPropagation()}
>
<PdfMobileViewer url={objectUrl} />
<PdfMobileViewer url={objectUrl} blob={pdfBlob} />
</div>
)}
+12 -3
View File
@@ -27,7 +27,11 @@ const MAX_ZOOM = 4; // pinch can go a bit beyond the double-tap steps
const DOUBLE_TAP_MS = 300;
const DOUBLE_TAP_SLOP = 30; // px
export function PdfMobileViewer({ url }: { url: string }) {
// `blob` is the document itself; `url` (a blob: URL for the same bytes) is only
// used for the open-in-new-tab fallback. pdf.js must be fed the bytes, not the
// URL: fetching a blob: URL is governed by CSP connect-src, which doesn't (and
// shouldn't need to) allow blob:, so getDocument({ url }) is blocked (#871).
export function PdfMobileViewer({ url, blob }: { url: string; blob: Blob }) {
const rootRef = useRef<HTMLDivElement>(null);
const pagesRef = useRef<HTMLDivElement>(null);
const zoom = useRef({ step: 0, scale: 1 });
@@ -51,7 +55,12 @@ export function PdfMobileViewer({ url }: { url: string }) {
import.meta.url,
).toString();
loadingTask = pdfjs.getDocument({ url });
// The Uint8Array is created fresh from the blob, so pdf.js taking
// ownership of (and transferring) the buffer is fine.
const data = new Uint8Array(await blob.arrayBuffer());
if (cancelled) return;
loadingTask = pdfjs.getDocument({ data });
const doc = await loadingTask.promise;
if (cancelled) return;
@@ -92,7 +101,7 @@ export function PdfMobileViewer({ url }: { url: string }) {
cancelled = true;
void loadingTask?.destroy().catch(() => {});
};
}, [url]);
}, [blob]);
// Gesture zoom. 1-finger double-tap cycles the steps (fit -> 2x -> 3x ->
// fit); 2-finger pinch zooms continuously up to MAX_ZOOM. Both drive a