Staff report: inline lightbox for image/PDF attachments
Adds a /api/staff/file proxy that re-streams Civi attachments with Content-Disposition: inline so a native <dialog> lightbox can preview images and PDFs in place. Office docs keep their plain download link and gain a "View in Google Docs" secondary link (uses the Civi-signed URL so Google can fetch without our staff key). Also threads mime through /api/staff/report (Attachment.get mime_type) so the dispatcher picks the right affordance without relying solely on filename inference.
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* GET /api/staff/file?id=<fileId>&org=<orgId>&key=<STAFF_REPORT_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<AttachmentRow | null> {
|
||||
const res = await civi3<AttachmentRow>("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<boolean> {
|
||||
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<string, string> = {
|
||||
"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 });
|
||||
}
|
||||
Reference in New Issue
Block a user