Files
WebForm-mw/lib/civicrm.ts
T
Joel Brock c9b610590e Fail closed in verifyChecksum; drop the Contact.get fallback
If Contact.validateChecksum threw for any reason, verifyChecksum retried
with Contact.get plus an unknown `checksum` param. Our API key is
privileged, so if Civi ever ignored that param the probe would resolve
the contact and accept any checksum. Treat errors as invalid instead,
and reject non-positive/non-integer contact ids up front.
2026-10-30 11:17:52 -07:00

326 lines
11 KiB
TypeScript

/**
* CiviCRM APIv4 client.
*
* Reads credentials from environment variables. Falls back to STUB mode if
* any required env var is missing — STUB mode returns mock data so the UI
* can be developed without a live CiviCRM instance.
*
* Env vars:
* CIVI_BASE_URL e.g. https://crm.fci.coop
* CIVI_API_KEY per-user API key (Civi user "API Key" property)
* CIVI_SITE_KEY site-wide key (from civicrm.settings.php)
* CIVI_HTTP_AUTH_USER (optional) HTTP Basic Auth username, if the site
* itself sits behind webserver-level basic auth
* (common on staging/dev). When set together with
* CIVI_HTTP_AUTH_PASS, every request adds an
* `Authorization: Basic <base64>` header.
* CIVI_HTTP_AUTH_PASS (optional) HTTP Basic Auth password.
*
* Auth strategy may need adjustment depending on your CiviCRM auth extension
* (AuthX vs stock APIv3-style site_key/api_key). The header style here
* matches the AuthX pattern; classic API3 users may need different headers.
*/
export interface CiviApiOptions {
/** Override env CIVI_BASE_URL for one-off calls (e.g. tests). */
baseUrl?: string;
}
export interface CiviApiResponse<T = unknown> {
values: T[];
count?: number;
}
const STUB_LOG_PREFIX = "[civi:STUB]";
function isStubMode(): boolean {
return !(
process.env.CIVI_BASE_URL &&
process.env.CIVI_API_KEY &&
process.env.CIVI_SITE_KEY
);
}
/**
* Generic APIv4 call. `entity` is e.g. "Contact" / "Activity" / "Relationship".
* `action` is the APIv4 action name. `params` is the JSON params object.
*/
export async function civi<T = unknown>(
entity: string,
action: string,
params: Record<string, unknown>,
opts: CiviApiOptions = {},
): Promise<CiviApiResponse<T>> {
if (isStubMode()) {
console.warn(`${STUB_LOG_PREFIX} ${entity}.${action} — env not set, returning empty values`);
return { values: [] };
}
const base = opts.baseUrl ?? process.env.CIVI_BASE_URL!;
const url = `${base}/civicrm/ajax/api4/${entity}/${action}`;
const body = new URLSearchParams({
params: 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!,
};
// Webserver-level HTTP Basic Auth (e.g. site is gated by .htaccess on staging).
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 ${entity}.${action} failed (${res.status}): ${text}`);
}
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 };
}
/**
* Multipart APIv3 call. Used for binary uploads — APIv4 File.create stores
* the `content` field verbatim (no base64 decoding), so files come back
* corrupted. APIv3 Attachment.create accepts the file via the standard
* multipart `file` part (read from $_FILES on the server) which preserves
* the bytes exactly.
*
* Caller provides `params` (non-binary metadata) and `file` (the binary +
* filename + mime). Authentication is the same AuthX headers as civi3().
*/
export async function civi3Upload<T = unknown>(
entity: string,
action: string,
params: Record<string, unknown>,
file: { bytes: Uint8Array; filename: string; mime: string },
opts: CiviApiOptions = {},
): Promise<CiviApiResponse<T>> {
if (isStubMode()) {
console.warn(
`${STUB_LOG_PREFIX} v3-multipart ${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 form = new FormData();
form.append("entity", entity);
form.append("action", action);
form.append("json", JSON.stringify(params));
// Wrap the Uint8Array in a fresh ArrayBuffer slice so Blob's typing
// (which only accepts ArrayBuffer, not the wider ArrayBufferLike) is
// happy. The slice is a no-op on real Uint8Array inputs.
const fileBuf = file.bytes.buffer.slice(
file.bytes.byteOffset,
file.bytes.byteOffset + file.bytes.byteLength,
) as ArrayBuffer;
form.append(
"file",
new Blob([fileBuf], { type: file.mime }),
file.filename,
);
// Do NOT set Content-Type — fetch sets multipart/form-data with the
// correct boundary automatically when body is a FormData.
const headers: Record<string, string> = {
"X-Civi-Auth": `Bearer ${process.env.CIVI_API_KEY}`,
"X-Civi-Key": process.env.CIVI_SITE_KEY!,
"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: form,
cache: "no-store",
});
if (!res.ok) {
const text = await res.text();
throw new Error(
`CiviCRM v3 ${entity}.${action} (multipart) 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} (multipart) 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 };
}
/**
* POST a multipart request directly to an arbitrary Civi route. Used for
* extension endpoints that handle multipart uploads natively (the v3/v4
* ajax/rest path silently drops $_FILES on this install, and JSON+base64
* stores the literal base64 text on disk).
*
* The caller's `path` is appended to CIVI_BASE_URL. AuthX headers are sent
* the same way as the other helpers. Returns the parsed JSON body.
*/
export async function civiMultipart<T = unknown>(
path: string,
fields: Record<string, string>,
file: { bytes: Uint8Array; filename: string; mime: string },
opts: CiviApiOptions = {},
): Promise<T> {
if (isStubMode()) {
console.warn(`${STUB_LOG_PREFIX} multipart ${path} — env not set`);
return {} as T;
}
const base = opts.baseUrl ?? process.env.CIVI_BASE_URL!;
const url = `${base.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
const form = new FormData();
for (const [k, v] of Object.entries(fields)) form.append(k, v);
const fileBuf = file.bytes.buffer.slice(
file.bytes.byteOffset,
file.bytes.byteOffset + file.bytes.byteLength,
) as ArrayBuffer;
form.append("file", new Blob([fileBuf], { type: file.mime }), file.filename);
const headers: Record<string, string> = {
"X-Civi-Auth": `Bearer ${process.env.CIVI_API_KEY}`,
"X-Civi-Key": process.env.CIVI_SITE_KEY!,
"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: form,
cache: "no-store",
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Civi POST ${path} failed (${res.status}): ${text}`);
}
return (await res.json()) as T;
}
/**
* Validate a contact checksum (cid + cs) against CiviCRM via APIv4
* Contact.validateChecksum.
*
* Fails closed: any API error is treated as an invalid checksum. There is
* deliberately no fallback — our API key is privileged, so any probe that
* merely checks "does the contact resolve" would accept every checksum.
* /api/health confirms validateChecksum is available on the install.
*/
export async function verifyChecksum(cid: string, cs: string): Promise<boolean> {
if (isStubMode()) {
// STUB: any non-empty cs is "valid" so the UI can be exercised locally.
return Boolean(cid && cs);
}
const contactId = Number(cid);
if (!Number.isSafeInteger(contactId) || contactId <= 0) return false;
try {
const res = await civi<{ valid: boolean }>("Contact", "validateChecksum", {
contactId,
checksum: cs,
});
return Boolean(res.values?.[0]?.valid);
} catch (e) {
console.error("[civi] validateChecksum failed; treating as invalid:", e instanceof Error ? e.message : String(e));
return false;
}
}