Merge branch 'fix/slip-pdf-iframe-proxy' into main
This commit is contained in:
commit
ce773322f7
2 changed files with 121 additions and 2 deletions
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue