/** * 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. Per-file ownership probe: the requested fileId must appear as a * value in one of the org's file-typed custom-field columns, OR in * one of the org's activities' file-typed columns. This is the same * ownership chain the staff report uses to surface the file in the * first place. NOT enforced via civicrm_entity_file — that linkage * is metadata-only here (upload anchors to submitter, not org). * * 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 { civi, civi3 } from "@/lib/civicrm"; import { resolveMime } from "@/lib/mime.mjs"; const MAX_BYTES = 10 * 1024 * 1024; // Only mimes the lightbox actually renders inline are allowed through the // `Content-Disposition: inline` path. Everything else gets coerced to // application/octet-stream + attachment so it always downloads. // // Why this matters: our /api/upload route validates uploaded mimes // against an allowlist, but the underlying civicrm_file row can be // populated by other paths too — a Civi admin uploading directly through // the CiviCRM UI, a future Civi import, etc. If any of those routes // stored mime_type="text/html" or "image/svg+xml", an inline serve from // this same-origin proxy would let arbitrary script run against // /api/* and the staff key. Defence in depth: don't trust mime_type // when the response carries `inline`. // // Explicitly NOT in this set: svg (script-bearing), html, xml, any text/*. const SAFE_INLINE_MIMES = new Set([ "image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf", ]); 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; } async function fetchAttachment(fileId: number): Promise { const res = await civi3("Attachment", "get", { id: fileId, return: "id,url,mime_type,name", sequential: 1, }); return res.values?.[0] ?? null; } // Same groups /api/staff/report scans. Kept in sync deliberately: this // proxy must only authorise files reachable through the same set of // fields the report itself surfaces. const ACTIVITY_GROUP_NAMES = [ "Check_in_data__organizing_", "Stage_1", "Stage_2", "Stage_3", "Stage_4", "Stage_5", ]; const ORG_GROUP_NAMES = ["Food_Co_op_Organizing"]; const ACTIVITY_TYPE_NAME = "Check-in (organizing)"; interface FileFieldRefs { org: string[]; activity: string[]; } /** * Discover the APIv4 field references for File-typed custom fields the * staff report cares about. Returns refs like "Stage_1.Vision_Upload" * split by whether they live on the Organization Contact or on Activities. */ async function discoverFileFieldRefs(): Promise { const res = await civi<{ name: string; data_type: string; "custom_group_id.name": string; }>("CustomField", "get", { select: ["name", "data_type", "custom_group_id.name"], where: [ ["custom_group_id.name", "IN", [...ACTIVITY_GROUP_NAMES, ...ORG_GROUP_NAMES]], ["data_type", "=", "File"], ["is_active", "=", true], ], limit: 500, }); const org: string[] = []; const activity: string[] = []; for (const row of res.values ?? []) { const group = row["custom_group_id.name"]; const ref = `${group}.${row.name}`; if (ORG_GROUP_NAMES.includes(group)) org.push(ref); else if (ACTIVITY_GROUP_NAMES.includes(group)) activity.push(ref); } return { org, activity }; } /** * Confirm fileId is reachable from this org through the same column * ownership the staff report uses. * * Implementation: fetch the org row and the org's activities, selecting * the file-typed custom-field columns, then check in JS whether any * column value equals fileId. We avoid APIv4 OR clauses against custom * fields because that combination has been fragile in practice; the * SELECT-and-compare path is the same shape /api/staff/report uses * successfully. * * We do NOT use civicrm_entity_file for this check: our upload route * anchors files to the submitter's contact id (not the org's), so that * linkage doesn't reflect ownership. The custom-field column is the * authoritative chain. */ async function fileBelongsToOrg(fileId: number, orgId: number): Promise { let refs: FileFieldRefs; try { refs = await discoverFileFieldRefs(); } catch (e) { const msg = e instanceof Error ? e.message : String(e); console.error("[staff/file] CustomField.get failed:", msg); return false; } const matches = (rows: Array>, fieldRefs: string[]): boolean => { for (const row of rows) { for (const ref of fieldRefs) { const v = row[ref]; if (v === undefined || v === null || v === "") continue; if (Number(v) === fileId) return true; } } return false; }; const orgProbe = refs.org.length > 0 ? civi>("Contact", "get", { where: [["id", "=", orgId]], select: ["id", ...refs.org], limit: 1, }).catch((e: unknown) => { console.error( "[staff/file] Contact.get probe failed:", e instanceof Error ? e.message : String(e), ); return { values: [] as Array> }; }) : Promise.resolve({ values: [] as Array> }); const activityProbe = refs.activity.length > 0 ? civi>("Activity", "get", { where: [ ["target_contact_id", "=", orgId], ["activity_type_id:name", "=", ACTIVITY_TYPE_NAME], ], select: ["id", ...refs.activity], limit: 500, }).catch((e: unknown) => { console.error( "[staff/file] Activity.get probe failed:", e instanceof Error ? e.message : String(e), ); return { values: [] as Array> }; }) : Promise.resolve({ values: [] as Array> }); const [orgRes, actRes] = await Promise.all([orgProbe, activityProbe]); return ( matches(orgRes.values ?? [], refs.org) || matches(actRes.values ?? [], refs.activity) ); } 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 }); } // Authorisation: prove this file actually belongs to `orgId` via the // custom-field columns the staff report itself surfaces. Without this a // staff key (which is org-agnostic) could be used to enumerate file ids // outside any report context. const owned = await fileBelongsToOrg(fileId, orgId); if (!owned) { 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 resolved = resolveMime( row.mime_type ?? upstreamRes.headers.get("content-type"), row.name, ); // Inline is only allowed for mimes the lightbox actually renders. Anything // else (incl. an attacker-controlled mime_type from a non-allowlisted // upload path) is downgraded to octet-stream + attachment so the browser // downloads instead of executing. const inlineSafe = !wantsDownload && SAFE_INLINE_MIMES.has(resolved); const mime = inlineSafe ? resolved : "application/octet-stream"; const disposition = inlineSafe ? "inline" : "attachment"; const safeName = (row.name ?? `file-${fileId}`).replace(/[\r\n"\\]/g, "_"); const headers: Record = { "Content-Type": mime, "Content-Disposition": `${disposition}; filename="${safeName}"`, "Cache-Control": "private, no-store", "X-Content-Type-Options": "nosniff", }; // For attachment responses, layer on a strict sandbox CSP as defence in // depth — the file is being downloaded so the CSP has no UX effect, but // if a future change ever flips it to inline by mistake, the sandbox // blocks script + plugins. For inline responses we rely on the SAFE // MIME allowlist + nosniff + the app's global CSP, because a strict // `sandbox` here breaks Chrome's PDF viewer (it can't load fonts or // plugin-mode rendering under sandbox). if (!inlineSafe) { headers["Content-Security-Policy"] = "sandbox; default-src 'none'"; } if (Number.isFinite(contentLength)) { headers["Content-Length"] = String(contentLength); } return new NextResponse(upstreamRes.body, { status: 200, headers }); }