Every JSON-based upload path on this Civi stores the `content` field
verbatim on disk — confirmed against both APIv4 File.create AND APIv3
Attachment.create (both came back as base64 text in hex dumps). The
multipart `file` part to /civicrm/ajax/rest is also a dead end: APIv3
Attachment.create on this install doesn't see $_FILES (rejected with
"Mandatory key(s) missing: id or content or options.move-file").
The one path Civi honors is APIv3 Attachment.create + options.move-file
— pointing at a filesystem path the Civi server can read. So expose a
tiny multipart endpoint in the WebForm-mw Civi extension that copies
PHP's $_FILES['file']['tmp_name'] into the API call, then return the
new file id as JSON. PHP's $_FILES preserves binary natively.
Civi extension (requires admin deploy):
- CRM/WebformMw/Page/Upload.php : multipart POST handler. Validates
the upload, requires `access CiviCRM`, whitelists entity_table to
civicrm_contact|civicrm_activity, calls Attachment.create with
move-file pointing at the tmp upload, returns {id, name} JSON.
- xml/Menu/webform_mw.xml : registers civicrm/webform-mw/upload.
WebForm-mw side:
- lib/civicrm.ts : new civiMultipart() helper. POSTs multipart to an
arbitrary Civi path (not /civicrm/ajax/rest) with the same AuthX
headers. Returns the parsed JSON body.
- app/api/upload/route.ts : send the upload's bytes via civiMultipart
to civicrm/webform-mw/upload. Comment-block now records all four
upload paths we tried so a future reader doesn't repeat the cycle.
Deploy: admin syncs the updated civi-extension/webform-mw/ directory
and Disable/Re-enables the extension (or runs cv flush) so the new
menu route is registered.
329 lines
11 KiB
TypeScript
329 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.
|
|
*
|
|
* APIv4 exposes Contact.validateChecksum in newer Civi versions. For older
|
|
* versions you may need to call Contact.get with the cs param and verify
|
|
* the contact resolves. We use validateChecksum here and fall back to a
|
|
* Contact.get probe if it returns a "missing API" error.
|
|
*/
|
|
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);
|
|
}
|
|
try {
|
|
const res = await civi<{ valid: boolean }>("Contact", "validateChecksum", {
|
|
contactId: Number(cid),
|
|
checksum: cs,
|
|
});
|
|
return Boolean(res.values?.[0]?.valid);
|
|
} catch (e) {
|
|
// Fallback: try Contact.get with the checksum as `cs` URL param. If the
|
|
// contact resolves, the checksum is valid.
|
|
const res = await civi<{ id: number }>("Contact", "get", {
|
|
where: [["id", "=", Number(cid)]],
|
|
select: ["id"],
|
|
checksum: cs,
|
|
});
|
|
return Array.isArray(res.values) && res.values.length === 1;
|
|
}
|
|
}
|