APIv4 File.create on this Civi install stores the `content` field
verbatim — no base64 decoding. The hex dump of a downloaded file
confirms it: bytes start with 69 56 42 4f ("iVBO...") which is the
base64 encoding of the PNG header (89 50 4e 47), not the header itself.
Every file uploaded via the form has been corrupt on disk since launch.
JSON can't carry binary safely (high bytes break UTF-8), so the fix is
to stop trying. APIv3 Attachment.create accepts a multipart `file` part
the standard way (read from $_FILES on the server side) which preserves
bytes exactly.
Changes:
- lib/civicrm.ts: new civi3Upload() helper. POSTs multipart/form-data
with `entity`, `action`, `json`, and `file` parts to /civicrm/ajax/rest
using the same AuthX headers as civi3(). Wraps the Uint8Array into an
ArrayBuffer slice so Blob's narrower BlobPart typing accepts it.
- app/api/upload/route.ts: replace the v4 File.create JSON call with
civi3Upload("Attachment", "create", ...). Attachment.create requires
an entity context, so anchor to the form-filler's contact id. Our
custom-field flow uses the returned file id directly (no entity_file
linkage needed for prefill/download), so the extra civicrm_entity_file
row is metadata-only.
Note: existing files in Civi (uploaded via the buggy path) are still
corrupt on disk. New uploads will be intact. To recover the old ones
the user would need to re-upload via the form, or run a one-off
backfill that reads the base64 text out of /civicrm.files/upload/ and
rewrites each file with its decoded bytes.
277 lines
9.1 KiB
TypeScript
277 lines
9.1 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 };
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|