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:
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user