diff --git a/app/api/staff/file/route.ts b/app/api/staff/file/route.ts new file mode 100644 index 0000000..6864a52 --- /dev/null +++ b/app/api/staff/file/route.ts @@ -0,0 +1,174 @@ +/** + * 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 }); +} diff --git a/app/api/staff/report/route.ts b/app/api/staff/report/route.ts index 05ecb0b..a7d3733 100644 --- a/app/api/staff/report/route.ts +++ b/app/api/staff/report/route.ts @@ -193,7 +193,7 @@ function buildStubPayload(orgId: number): StaffReportPayload { { activityId: 9012, date: daysAgo(3), - value: { id: 4242, file_name: "co-op-vision.pdf" }, + value: { id: 4242, file_name: "co-op-vision.pdf", mime: "application/pdf" }, }, ], }, @@ -373,6 +373,7 @@ async function buildLivePayload(orgId: number): Promise { } const urlByFileId = new Map(); + const mimeByFileId = new Map(); if (fileIds.size > 0) { // APIv3 Attachment.get doesn't accept an IN-clause cleanly on this Civi // install — passing {IN: [...]} for `id` crashes Civi's error renderer @@ -382,11 +383,15 @@ async function buildLivePayload(orgId: number): Promise { // small. Each request is independent; we issue them in parallel. const lookups = await Promise.allSettled( Array.from(fileIds).map((fid) => - civi3<{ id: string | number; url?: string }>("Attachment", "get", { - id: fid, - return: "id,url", - sequential: 1, - }).then((r) => ({ fid, row: r.values?.[0] })), + civi3<{ id: string | number; url?: string; mime_type?: string }>( + "Attachment", + "get", + { + id: fid, + return: "id,url,mime_type", + sequential: 1, + }, + ).then((r) => ({ fid, row: r.values?.[0] })), ), ); for (const result of lookups) { @@ -401,6 +406,9 @@ async function buildLivePayload(orgId: number): Promise { if (row && typeof row.url === "string" && row.url.length > 0) { urlByFileId.set(fid, row.url); } + if (row && typeof row.mime_type === "string" && row.mime_type.length > 0) { + mimeByFileId.set(fid, row.mime_type); + } } } @@ -447,6 +455,7 @@ async function buildLivePayload(orgId: number): Promise { id: raw, file_name: typeof fname === "string" ? fname : undefined, url: Number.isFinite(fid) ? urlByFileId.get(fid) : undefined, + mime: Number.isFinite(fid) ? mimeByFileId.get(fid) : undefined, }; } return { @@ -480,6 +489,7 @@ async function buildLivePayload(orgId: number): Promise { id: v, file_name: typeof fname === "string" ? fname : undefined, url: Number.isFinite(fid) ? urlByFileId.get(fid) : undefined, + mime: Number.isFinite(fid) ? mimeByFileId.get(fid) : undefined, }; } entries.push({ activityId: row.id, date: row.activity_date_time, value }); diff --git a/components/StaffReportView.tsx b/components/StaffReportView.tsx index df48e5f..b1eb6b3 100644 --- a/components/StaffReportView.tsx +++ b/components/StaffReportView.tsx @@ -19,6 +19,7 @@ import { computeDateRange, } from "./report/FieldHistory"; import { LoadingState, EmptyState, ErrorState } from "./report/ReportStates"; +import { FileLink } from "./report/FileLink"; interface StaffReportViewProps { org: number; @@ -207,6 +208,8 @@ export function StaffReportView({ section={section} options={data.options} civiBaseUrl={civiBaseUrl} + org={org} + authKey={authKey} /> ))} @@ -271,10 +274,14 @@ function StaffSection({ section, options, civiBaseUrl, + org, + authKey, }: { section: StaffReportSection; options: Record; civiBaseUrl: string; + org: number; + authKey: string; }) { const filled = section.fields.filter((f) => f.history.length > 0); const empty = section.fields.filter((f) => f.history.length === 0); @@ -328,6 +335,8 @@ function StaffSection({ field={f} options={options} civiBaseUrl={civiBaseUrl} + org={org} + authKey={authKey} /> ))} @@ -374,10 +383,14 @@ function CompactFieldRow({ field, options, civiBaseUrl, + org, + authKey, }: { field: StaffReportField; options: Record; civiBaseUrl: string; + org: number; + authKey: string; }) { const [open, setOpen] = useState(false); const latest = field.history[0]; @@ -394,6 +407,8 @@ function CompactFieldRow({ entry={latest} options={options} civiBaseUrl={civiBaseUrl} + org={org} + authKey={authKey} /> {latest.date ? ( @@ -431,6 +446,8 @@ function CompactFieldRow({ entry={e} options={options} civiBaseUrl={civiBaseUrl} + org={org} + authKey={authKey} /> @@ -446,41 +463,33 @@ function FieldValue({ entry, options, civiBaseUrl, + org, + authKey, }: { field: StaffReportField; entry: FieldHistoryEntry; options: Record; civiBaseUrl: string; + org: number; + authKey: string; }) { if (field.descriptor.render === "file") { const v = entry.value as - | { id?: number | string; file_name?: string; url?: string } + | { id?: number | string; file_name?: string; url?: string; mime?: string } | null; if (!v || v.id === undefined) return ; const id = String(v.id); const name = v.file_name ?? `file-${id}`; - // Prefer the Civi-signed URL (carries the fcs JWT) returned by - // Attachment.get. If absent, fall back to the WebForm-mw Civi extension's - // file-redirect route — it mints the fcs server-side and 302s to the - // real /civicrm/file URL. (Hitting /civicrm/file?id=X bare crashes Civi - // on a null fcs JWT decode.) - let href = "#"; - if (v.url) { - href = v.url.startsWith("http") - ? v.url - : `${civiBaseUrl}${v.url.startsWith("/") ? "" : "/"}${v.url}`; - } else if (civiBaseUrl) { - href = `${civiBaseUrl}/civicrm/webform-mw/file?id=${encodeURIComponent(id)}`; - } return ( - - {name} - + ); } return ( diff --git a/components/report/AttachmentLightbox.tsx b/components/report/AttachmentLightbox.tsx new file mode 100644 index 0000000..63eacb2 --- /dev/null +++ b/components/report/AttachmentLightbox.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +/** + * Modal preview for image and PDF attachments. + * + * Uses the native element for focus trap, Esc-to-close, and + * inert-background semantics — saves ~100 lines of bespoke a11y wiring + * that we'd otherwise have to maintain. + * + * For an `image/*` mime, renders an . For `application/pdf`, an + *