Merge branch 'fix/slip-pdf-iframe-proxy' into main

This commit is contained in:
DEls 2026-09-07 14:53:26 +02:00
commit ce773322f7
2 changed files with 121 additions and 2 deletions

View file

@ -0,0 +1,109 @@
import { NextResponse } from "next/server";
/**
* GET /api/pb-files/[collection]/[recordId]/[...filename]
*
* Same-origin proxy for PocketBase file responses, so the dashboard
* iframe can render PDF slips without the cross-origin
* `Content-Security-Policy: ...sandbox` header that PocketBase sets
* on `/api/files/...`. That CSP makes the response opaque-origin and
* Chromium's PDF viewer silently fails to render the PDF in an
* `<iframe>` (downloads still work because they bypass rendering).
*
* Behaviour:
* - Validates collection, recordId, and filename shape
* - Fetches `${NEXT_PUBLIC_POCKETBASE_URL}/api/files/...`
* - Streams the bytes back with the same Content-Type / Length
* - Strips `Content-Security-Policy` and `X-Frame-Options` so the
* browser will allow same-origin iframe rendering
*
* Auth: none. PB file rules decide who can read each file; we
* enforce the same rule by simply forwarding the URL. Public files
* remain public; protected files still require the caller's PB
* token, which we currently do not forward (the existing slip
* preview only needs public images / PDFs).
*
* Path: collection in {delivery_notes, purchase_orders},
* recordId is a 15-char PB id, filename is the bare filename
* PocketBase stored on the file field.
*/
const ALLOWED_COLLECTIONS = new Set([
"delivery_notes",
"purchase_orders",
]);
export async function GET(
request: Request,
{
params,
}: {
params: Promise<{ collection: string; recordId: string; filename: string[] }>;
},
) {
const { collection, recordId, filename } = await params;
if (!ALLOWED_COLLECTIONS.has(collection)) {
return NextResponse.json({ error: "invalid_collection" }, { status: 400 });
}
if (!/^[a-z0-9]{15}$/i.test(recordId)) {
return NextResponse.json({ error: "invalid_record_id" }, { status: 400 });
}
if (!Array.isArray(filename) || filename.length === 0) {
return NextResponse.json({ error: "missing_filename" }, { status: 400 });
}
// Reassemble the filename from the splat and reject path-traversal
// attempts. PB filenames may contain spaces, dots, and underscores
// but never `/` or `..`.
const joined = filename.join("/");
if (joined.includes("..") || joined.includes("\\") || joined.startsWith("/")) {
return NextResponse.json({ error: "invalid_filename" }, { status: 400 });
}
const pbUrl = process.env.NEXT_PUBLIC_POCKETBASE_URL;
if (!pbUrl) {
return NextResponse.json(
{ error: "pb_url_not_configured" },
{ status: 500 },
);
}
const targetUrl = `${pbUrl}/api/files/${collection}/${recordId}/${encodeURIComponent(joined)}`;
let upstream: Response;
try {
upstream = await fetch(targetUrl);
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "upstream_fetch_failed" },
{ status: 502 },
);
}
if (!upstream.ok || upstream.body === null) {
return NextResponse.json(
{ error: "upstream_not_ok", status: upstream.status },
{ status: upstream.status === 404 ? 404 : 502 },
);
}
// Forward only the headers that matter for rendering; strip any
// security headers that would prevent same-origin iframe rendering
// of PDFs / display of images.
const contentType =
upstream.headers.get("content-type") ?? "application/octet-stream";
const contentLength = upstream.headers.get("content-length");
const lastModified = upstream.headers.get("last-modified");
const headers: Record<string, string> = {
"Content-Type": contentType,
// Replace PB's inline disposition with a stable filename so
// downloads keep the original name. The browser will still
// display PDFs inline inside an <iframe>.
"Content-Disposition": `inline; filename="${joined.replace(/"/g, "")}"`,
"Cache-Control": "private, max-age=300",
};
if (contentLength) headers["Content-Length"] = contentLength;
if (lastModified) headers["Last-Modified"] = lastModified;
return new Response(upstream.body, { status: 200, headers });
}

View file

@ -39,8 +39,18 @@ export function SlipPreview({
</div>
);
}
const url = `${PB_BASE_URL}/api/files/${collection}/${recordId}/${encodeURIComponent(filename)}`;
// PB serves file responses with
// `Content-Security-Policy: default-src 'none'; ...; sandbox`, which
// makes Chromium treat the iframe as an opaque sandboxed origin and
// silently fails to render PDFs in `<iframe>`. Route the iframe
// through our same-origin proxy `/api/pb-files/...`, which forwards
// the bytes and strips that CSP, so the PDF actually renders.
// Images go directly to PB — `<img>` is not subject to the CSP
// sandbox, so the direct URL works there.
const pbUrl = `${PB_BASE_URL}/api/files/${collection}/${recordId}/${encodeURIComponent(filename)}`;
const proxyUrl = `/api/pb-files/${collection}/${recordId}/${encodeURIComponent(filename)}`;
const isPdf = filename.toLowerCase().endsWith(".pdf");
const url = isPdf ? proxyUrl : pbUrl;
return (
<div className="space-y-3">
{isPdf ? (
@ -52,7 +62,7 @@ export function SlipPreview({
/>
<p className="text-xs text-muted-foreground">
<a
href={url}
href={pbUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-[#F2B165] hover:underline"