Replaces the dropped entity_file→org check with a probe against the
actual ownership chain — the file_id stored in a custom-field column
on the org or on one of its activities.
For each request:
1. Discover file-typed CustomField refs in ACTIVITY_GROUP_NAMES and
ORG_GROUP_NAMES (one CustomField.get).
2. In parallel, probe:
- Contact.get(id=orgId) WHERE any org file field == fileId
- Activity.get(target=orgId) WHERE any activity file field == fileId
using APIv4 OR clauses.
3. Allow only if at least one probe returns a hit.
This is the same ownership the staff report itself uses to surface the
file — the proxy now refuses to broker bytes for any file id that
wouldn't appear in the org's own report. civicrm_entity_file remains
unused for auth (it's anchored to the submitter, not the org).
299 lines
11 KiB
TypeScript
299 lines
11 KiB
TypeScript
/**
|
|
* 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. 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<string>([
|
|
"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<AttachmentRow | null> {
|
|
const res = await civi3<AttachmentRow>("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<FileFieldRefs> {
|
|
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.
|
|
*
|
|
* Strategy: probe both ownership sides in parallel using APIv4 OR clauses.
|
|
* - Contact.get for the org itself, asking whether ANY org-side file
|
|
* custom field equals fileId.
|
|
* - Activity.get for activities with target_contact_id=orgId, asking
|
|
* whether ANY activity-side file custom field equals fileId.
|
|
* If either query returns a row, the file genuinely belongs to this org.
|
|
*
|
|
* 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) because
|
|
* Attachment.create needs *some* entity, but the real ownership is in
|
|
* the custom-field column. Trusting entity_file would refuse legitimate
|
|
* files and admit-or-refuse incorrectly for the rest.
|
|
*/
|
|
async function fileBelongsToOrg(fileId: number, orgId: number): Promise<boolean> {
|
|
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 probes: Array<Promise<{ values?: unknown[] }>> = [];
|
|
|
|
if (refs.org.length > 0) {
|
|
probes.push(
|
|
civi<{ id: number }>("Contact", "get", {
|
|
where: [
|
|
["id", "=", orgId],
|
|
["OR", refs.org.map((ref) => [ref, "=", fileId])],
|
|
],
|
|
select: ["id"],
|
|
limit: 1,
|
|
}),
|
|
);
|
|
}
|
|
|
|
if (refs.activity.length > 0) {
|
|
probes.push(
|
|
civi<{ id: number }>("Activity", "get", {
|
|
where: [
|
|
["target_contact_id", "=", orgId],
|
|
["activity_type_id:name", "=", ACTIVITY_TYPE_NAME],
|
|
["OR", refs.activity.map((ref) => [ref, "=", fileId])],
|
|
],
|
|
select: ["id"],
|
|
limit: 1,
|
|
}),
|
|
);
|
|
}
|
|
|
|
if (probes.length === 0) return false;
|
|
|
|
const results = await Promise.allSettled(probes);
|
|
return results.some(
|
|
(r) => r.status === "fulfilled" && Array.isArray(r.value.values) && r.value.values.length > 0,
|
|
);
|
|
}
|
|
|
|
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<string, string> = {
|
|
"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 });
|
|
}
|