Staff report: signed file URLs via APIv3 Attachment.get

APIv4 Attachment isn't exposed on this Civi install (confirmed in the
June 2026 upload-spike notes), so the Attachment.get call we shipped at
63e73e7 silently returned nothing and we fell through to the bare
/civicrm/file URL — which crashes Civi on a null fcs JWT decode.

APIv3 Attachment.get IS exposed and returns the signed URL with fcs
baked in (verified against id=150 in the user's API Explorer):

  "url": "https://.../civicrm/file?reset=1&id=150&fcs=<JWT>"

Changes:
- lib/civicrm.ts: add a civi3() helper that calls /civicrm/ajax/rest with
  AuthX headers, normalizing v3's array-or-keyed-object values shape into
  a plain array.
- app/api/staff/report/route.ts: replace the dead v4 Attachment.get with
  civi3("Attachment", "get", { id: {IN: [...]}, return: ["id","url"] }).
  Each file's url goes into the value payload as before, so the frontend
  needs no change.

Fallback chain remains intact: if Attachment.get fails (auth, endpoint
unavailable, etc.) the frontend still uses the /civicrm/webform-mw/file
extension route from b65bc6d/41467bd.
This commit is contained in:
Joel Brock
2026-06-10 11:05:00 -07:00
parent 41467bd4cf
commit 8ace5f41fe
2 changed files with 88 additions and 20 deletions
+20 -20
View File
@@ -13,7 +13,7 @@
import { NextRequest, NextResponse } from "next/server";
import { isStaffKeyValid } from "@/lib/staff-auth";
import { civi } from "@/lib/civicrm";
import { civi, civi3 } from "@/lib/civicrm";
import { mapCustomFieldRow } from "@/lib/staff-field-mapping.mjs";
import type {
StaffReportPayload,
@@ -352,13 +352,11 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
const rows = activityRes.values ?? [];
// Civi serves uploaded files at /civicrm/file?id=X&eid=Y&fcs=<JWT>; the fcs
// is a JWT signed with Civi's site key. Without it, the file handler
// crashes on a null JWT decode. We don't have the site key on this side,
// so ask Civi for signed URLs via APIv4 Attachment.get and pass them
// straight through to the client. If Attachment.get doesn't expose `url`
// on this Civi version, the frontend falls back to a bare /civicrm/file
// URL (still broken, but no worse than before).
// Civi serves uploaded files at /civicrm/file?id=X&fcs=<JWT>; the fcs is
// an HS256 JWT signed with the site key. Without it, /civicrm/file
// crashes on a null JWT decode. APIv4 Attachment isn't exposed on this
// install, but APIv3 Attachment.get is — and it returns `url` with the
// fcs already baked in. We pass the URL straight through to the client.
const fileIds = new Set<number>();
const collectId = (v: unknown) => {
if (v === null || v === undefined || v === "") return;
@@ -377,23 +375,25 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
const urlByFileId = new Map<number, string>();
if (fileIds.size > 0) {
try {
const attachRes = await civi<{ id: number; url?: string }>("Attachment", "get", {
select: ["id", "url"],
where: [["id", "IN", Array.from(fileIds)]],
// Bypass permission checks: we already gated this whole route on
// STAFF_REPORT_KEY, and we want every file the org's activities
// reference, regardless of which contact "owns" them.
checkPermissions: false,
limit: 0,
});
const attachRes = await civi3<{ id: string | number; url?: string }>(
"Attachment",
"get",
{
// APIv3 takes id as an IN-clause via the {IN: [...]} operator object.
id: { IN: Array.from(fileIds) },
return: ["id", "url"],
options: { limit: 0 },
},
);
for (const a of attachRes.values ?? []) {
if (typeof a.url === "string" && a.url.length > 0) {
urlByFileId.set(a.id, a.url);
const fid = Number(a.id);
if (Number.isFinite(fid) && typeof a.url === "string" && a.url.length > 0) {
urlByFileId.set(fid, a.url);
}
}
} catch (e) {
console.warn(
"[staff/report] Attachment.get failed; file links will lack fcs:",
"[staff/report] Attachment.get (v3) failed; file links will lack fcs:",
e instanceof Error ? e.message : String(e),
);
}
+68
View File
@@ -86,6 +86,74 @@ export async function civi<T = unknown>(
return (await res.json()) as CiviApiResponse<T>;
}
/**
* Legacy APIv3 call. Some Civi entities (notably Attachment) are exposed
* only via APIv3 on this install; this helper hits the universal
* /civicrm/ajax/rest endpoint with AuthX headers. Returns the normalized
* values list — APIv3 may return values as either an array or an object
* keyed by id, depending on version; we flatten to an array.
*/
export async function civi3<T = unknown>(
entity: string,
action: string,
params: Record<string, unknown>,
opts: CiviApiOptions = {},
): Promise<CiviApiResponse<T>> {
if (isStubMode()) {
console.warn(`${STUB_LOG_PREFIX} v3 ${entity}.${action} — env not set, returning empty values`);
return { values: [] };
}
const base = opts.baseUrl ?? process.env.CIVI_BASE_URL!;
const url = `${base}/civicrm/ajax/rest`;
const body = new URLSearchParams({
entity,
action,
json: JSON.stringify(params),
});
const headers: Record<string, string> = {
"Content-Type": "application/x-www-form-urlencoded",
"X-Civi-Auth": `Bearer ${process.env.CIVI_API_KEY}`,
"X-Civi-Key": process.env.CIVI_SITE_KEY!,
// v3's rest endpoint enforces this header as CSRF protection.
"X-Requested-With": "XMLHttpRequest",
};
if (process.env.CIVI_HTTP_AUTH_USER && process.env.CIVI_HTTP_AUTH_PASS) {
const creds = Buffer.from(
`${process.env.CIVI_HTTP_AUTH_USER}:${process.env.CIVI_HTTP_AUTH_PASS}`,
).toString("base64");
headers["Authorization"] = `Basic ${creds}`;
}
const res = await fetch(url, {
method: "POST",
headers,
body,
cache: "no-store",
});
if (!res.ok) {
const text = await res.text();
throw new Error(`CiviCRM v3 ${entity}.${action} failed (${res.status}): ${text}`);
}
const json = (await res.json()) as {
is_error?: number;
error_message?: string;
values?: T[] | Record<string, T>;
count?: number;
};
if (json.is_error) {
throw new Error(
`CiviCRM v3 ${entity}.${action} error: ${json.error_message ?? "unknown"}`,
);
}
const raw = json.values;
const values: T[] = Array.isArray(raw)
? raw
: raw && typeof raw === "object"
? Object.values(raw)
: [];
return { values, count: json.count };
}
/**
* Validate a contact checksum (cid + cs) against CiviCRM.
*