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
+30 -34
View File
@@ -25,7 +25,7 @@
*/
import { NextResponse } from "next/server";
import { civi3, verifyChecksum } from "@/lib/civicrm";
import { civiMultipart, verifyChecksum } from "@/lib/civicrm";
import { allFields } from "@/config/form";
import { rateLimit, clientIp } from "@/lib/rate-limit";
@@ -205,48 +205,44 @@ export async function POST(req: Request) {
);
}
// APIv3 Attachment.create with `content` as base64. Path history:
// Path history exhausted before we got here:
// 1. APIv4 File.create + content:base64 → stores base64 text on disk.
// 2. APIv3 Attachment.create + content:base64 → same; v3 doesn't decode either.
// 3. APIv3 Attachment.create + multipart file → /civicrm/ajax/rest drops $_FILES.
//
// 1. APIv4 File.create + content:base64 → stored base64 *text* on disk
// (Civi v4 doesn't decode). Every file came back corrupt.
// 2. APIv3 Attachment.create + multipart file part → Civi rejected with
// "Mandatory key(s) missing: id or content or options.move-file".
// The /civicrm/ajax/rest endpoint on this install doesn't expose
// $_FILES to the v3 action; only params['content'] is consulted.
// 3. APIv3 Attachment.create + content:base64 (this code) → Civi's v3
// Attachment.create has historically been the file-upload entry
// point used by Civi's own form widgets, and it auto-decodes the
// content field.
// The one path Civi reliably honors is options.move-file: APIv3
// Attachment.create with a server-side filesystem path. PHP's $_FILES
// preserves binary natively, so the WebForm-mw Civi extension exposes
// a tiny multipart endpoint that copies the upload's tmp_name into
// Attachment.create as options.move-file. We POST the file there.
//
// Attachment.create requires (entity_table, entity_id); our custom-field
// flow stores the file id directly in the custom column (no entity_file
// linkage needed for prefill/download), but the API still mandates an
// entity context, so we anchor to the form-filler's contact id. The
// resulting civicrm_entity_file row is metadata-only — Civi's custom-
// field joins ignore it.
// flow uses the returned file id directly in the custom column (no
// entity_file linkage needed), so we anchor to the form-filler's contact
// id and accept the metadata-only civicrm_entity_file row.
//
// Orphan files: civicrm_file + civicrm_entity_file rows persist if the
// user uploads and then abandons the form. Cleanup is handled by a
// CiviCRM scheduled job configured separately by the Civi admin.
// Orphan files: civicrm_file rows linger if the user uploads then
// abandons. Cleanup is handled by a CiviCRM scheduled job (separately
// configured by the Civi admin).
let fileId: number;
try {
const res = await civi3<{ id: string | number }>("Attachment", "create", {
entity_table: "civicrm_contact",
entity_id: Number(cid),
name: safeName,
mime_type: clientMime,
content: Buffer.from(bytes).toString("base64"),
sequential: 1,
});
const idRaw = res.values?.[0]?.id;
const id = typeof idRaw === "string" ? Number(idRaw) : idRaw;
if (!id || !Number.isFinite(id)) {
throw new Error("Attachment.create returned no id");
const res = await civiMultipart<{ id?: number; name?: string; error?: string }>(
"civicrm/webform-mw/upload",
{
entity_table: "civicrm_contact",
entity_id: String(Number(cid)),
name: safeName,
mime_type: clientMime,
},
{ bytes, filename: safeName, mime: clientMime },
);
if (!res || !res.id || !Number.isFinite(res.id)) {
throw new Error(res?.error ?? "Upload endpoint returned no id");
}
fileId = id;
fileId = Number(res.id);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error("[upload] Attachment.create failed:", msg);
console.error("[upload] Civi extension upload failed:", msg);
return NextResponse.json(
{ error: "Could not save the upload. Please try again." },
{ status: 502 },