/** * GET /api/staff/file?id=&org=&key= * * Server-side proxy that fetches a CiviCRM attachment and re-streams it * with `Content-Disposition: inline`, so the staff-report lightbox can * preview images and PDFs in-place. Civi's own `/civicrm/file` handler * always sends `attachment`, which forces a download — that's correct for * its UI but wrong for an embedded preview. * * Authorization layers: * 1. STAFF_REPORT_KEY query param (same gate as /api/staff/report). * 2. Server-side check that the requested file is actually linked to the * `org`. This prevents the staff key from being used to pull arbitrary * file ids out of CiviCRM — a file is reachable only if its * entity_table/entity_id ties back to the org (directly, for org * custom-field files; or via an activity's target_contact_id, for * activity custom-field files). * * The upstream fetch uses the URL Civi returns from `Attachment.get`, * which includes a freshly-minted `fcs` JWT. We don't carry any user * session cookies — that JWT is the auth for `/civicrm/file`. * * STUB MODE: if Civi env vars are unset, 404. Stub-mode previews aren't * meaningful (there are no real bytes to serve). */ import { NextRequest, NextResponse } from "next/server"; import { isStaffKeyValid } from "@/lib/staff-auth"; import { civi3 } from "@/lib/civicrm"; import { resolveMime } from "@/lib/mime.mjs"; const MAX_BYTES = 10 * 1024 * 1024; function isCiviStubMode(): boolean { return !( process.env.CIVI_BASE_URL && process.env.CIVI_API_KEY && process.env.CIVI_SITE_KEY ); } interface AttachmentRow { id: string | number; url?: string; mime_type?: string; name?: string; entity_table?: string; entity_id?: string | number; } async function fetchAttachment(fileId: number): Promise { const res = await civi3("Attachment", "get", { id: fileId, return: "id,url,mime_type,name,entity_table,entity_id", sequential: 1, }); return res.values?.[0] ?? null; } /** Confirm a file is reachable from `orgId`. Returns false on any uncertainty. */ async function fileBelongsToOrg(row: AttachmentRow, orgId: number): Promise { const entityId = Number(row.entity_id); if (!Number.isFinite(entityId) || entityId <= 0) return false; const entityTable = String(row.entity_table ?? ""); if (entityTable === "civicrm_contact") { return entityId === orgId; } if (entityTable === "civicrm_activity") { // The activity must have orgId in its target_contact_id list. APIv4 // exposes this as `target_contact_id` array; we just need a hit-check. try { const probe = await civi3<{ id: string | number }>("Activity", "get", { id: entityId, target_contact_id: orgId, return: "id", sequential: 1, }); return Array.isArray(probe.values) && probe.values.length > 0; } catch { return false; } } return false; } export async function GET(req: NextRequest) { const url = new URL(req.url); const key = url.searchParams.get("key"); const idStr = url.searchParams.get("id"); const orgStr = url.searchParams.get("org"); const wantsDownload = url.searchParams.get("dl") === "1"; if (!isStaffKeyValid(key)) { return new NextResponse("Not found", { status: 404 }); } const fileId = Number(idStr); const orgId = Number(orgStr); if (!idStr || !Number.isFinite(fileId) || fileId <= 0) { return NextResponse.json({ error: "Missing or invalid file id." }, { status: 400 }); } if (!orgStr || !Number.isFinite(orgId) || orgId <= 0) { return NextResponse.json({ error: "Missing or invalid org id." }, { status: 400 }); } if (isCiviStubMode()) { return new NextResponse("Not found", { status: 404 }); } let row: AttachmentRow | null; try { row = await fetchAttachment(fileId); } catch (e) { const msg = e instanceof Error ? e.message : String(e); console.error("[staff/file] Attachment.get failed:", msg); return NextResponse.json({ error: "Could not look up the file." }, { status: 502 }); } if (!row || !row.url) { return new NextResponse("Not found", { status: 404 }); } const belongs = await fileBelongsToOrg(row, orgId); if (!belongs) { // Don't differentiate from "not found" — leaking link existence to a // probe-with-wrong-org gives no useful info to a legit caller and a // little to an attacker. return new NextResponse("Not found", { status: 404 }); } // The signed URL Civi returns is sometimes a relative path (depends on // Civi config). Normalise against CIVI_BASE_URL so fetch() has an // absolute URL. const base = (process.env.CIVI_BASE_URL ?? "").replace(/\/+$/, ""); const upstream = row.url.startsWith("http") ? row.url : `${base}${row.url.startsWith("/") ? "" : "/"}${row.url}`; let upstreamRes: Response; try { upstreamRes = await fetch(upstream, { cache: "no-store" }); } catch (e) { const msg = e instanceof Error ? e.message : String(e); console.error("[staff/file] upstream fetch failed:", msg); return NextResponse.json({ error: "Upstream fetch failed." }, { status: 502 }); } if (!upstreamRes.ok || !upstreamRes.body) { return new NextResponse("Not found", { status: upstreamRes.status === 404 ? 404 : 502 }); } const contentLengthRaw = upstreamRes.headers.get("content-length"); const contentLength = contentLengthRaw ? Number(contentLengthRaw) : NaN; if (Number.isFinite(contentLength) && contentLength > MAX_BYTES) { return NextResponse.json({ error: "File too large for inline preview." }, { status: 413 }); } const mime = resolveMime( row.mime_type ?? upstreamRes.headers.get("content-type"), row.name, ); const safeName = (row.name ?? `file-${fileId}`).replace(/[\r\n"\\]/g, "_"); const headers: Record = { "Content-Type": mime, "Content-Disposition": `${wantsDownload ? "attachment" : "inline"}; filename="${safeName}"`, "Cache-Control": "private, no-store", "X-Content-Type-Options": "nosniff", }; if (Number.isFinite(contentLength)) { headers["Content-Length"] = String(contentLength); } return new NextResponse(upstreamRes.body, { status: 200, headers }); }