From 8ace5f41fe306b07a611dfb6eff319f61829e8fe Mon Sep 17 00:00:00 2001 From: Joel Brock Date: Wed, 10 Jun 2026 11:05:00 -0700 Subject: [PATCH] Staff report: signed file URLs via APIv3 Attachment.get MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=" 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. --- app/api/staff/report/route.ts | 40 ++++++++++----------- lib/civicrm.ts | 68 +++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 20 deletions(-) diff --git a/app/api/staff/report/route.ts b/app/api/staff/report/route.ts index 248bd47..d597d4f 100644 --- a/app/api/staff/report/route.ts +++ b/app/api/staff/report/route.ts @@ -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 { const rows = activityRes.values ?? []; - // Civi serves uploaded files at /civicrm/file?id=X&eid=Y&fcs=; 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=; 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(); const collectId = (v: unknown) => { if (v === null || v === undefined || v === "") return; @@ -377,23 +375,25 @@ async function buildLivePayload(orgId: number): Promise { const urlByFileId = new Map(); 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), ); } diff --git a/lib/civicrm.ts b/lib/civicrm.ts index b4eced0..f0c81d2 100644 --- a/lib/civicrm.ts +++ b/lib/civicrm.ts @@ -86,6 +86,74 @@ export async function civi( return (await res.json()) as CiviApiResponse; } +/** + * 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( + entity: string, + action: string, + params: Record, + opts: CiviApiOptions = {}, +): Promise> { + 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 = { + "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; + 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. *