Upload: route through Civi extension multipart endpoint

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.
This commit is contained in:
Joel Brock
2026-06-10 12:28:43 -07:00
parent e42e3b70ef
commit 325615576a
4 changed files with 206 additions and 34 deletions
+52
View File
@@ -244,6 +244,58 @@ export async function civi3Upload<T = unknown>(
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.
*