Upload: APIv3 Attachment.create with base64 content (not multipart)

The previous multipart attempt (f74fd07) was rejected by Civi 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
v3 actions; only params['content'] is consulted. Our multipart `file`
part was ignored.

APIv3 Attachment.create has historically been the file-upload entry
point used by Civi's own form widgets and auto-decodes the `content`
field from base64, unlike v4 File.create which stores it verbatim.
Send the same shape we tried first (entity_table, entity_id, name,
mime_type, content=base64) but to v3 Attachment instead of v4 File.

The civi3Upload helper in lib/civicrm.ts is kept in place — it's not
useful for this endpoint but the multipart-POST shape may be needed
later for other Civi entities that do read $_FILES.
This commit is contained in:
Joel Brock
2026-06-10 12:16:32 -07:00
parent f74fd07ba5
commit e42e3b70ef
+29 -33
View File
@@ -25,7 +25,7 @@
*/ */
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { civi3Upload, verifyChecksum } from "@/lib/civicrm"; import { civi3, verifyChecksum } from "@/lib/civicrm";
import { allFields } from "@/config/form"; import { allFields } from "@/config/form";
import { rateLimit, clientIp } from "@/lib/rate-limit"; import { rateLimit, clientIp } from "@/lib/rate-limit";
@@ -205,43 +205,39 @@ export async function POST(req: Request) {
); );
} }
// APIv3 Attachment.create with a multipart `file` part. The earlier // APIv3 Attachment.create with `content` as base64. Path history:
// approach used APIv4 File.create with `content: base64String` and that
// Civi on this install stores the literal base64 *text* on disk — every
// file came back corrupted (hex 6956... = "iVBO..." = base64 PNG header).
// APIv3 Attachment.create reads $_FILES['file'] from the multipart body
// and writes the bytes through unchanged.
// //
// Attachment.create requires (entity_table, entity_id). Our custom-field // 1. APIv4 File.create + content:base64 → stored base64 *text* on disk
// flow stores the file id directly in the custom column (no // (Civi v4 doesn't decode). Every file came back corrupt.
// civicrm_entity_file linkage needed for prefill/download), but the API // 2. APIv3 Attachment.create + multipart file part → Civi rejected with
// still mandates an entity context for the upload call itself, so we // "Mandatory key(s) missing: id or content or options.move-file".
// anchor to the form-filler's contact id. The resulting civicrm_entity_file // The /civicrm/ajax/rest endpoint on this install doesn't expose
// row is metadata-only — Civi's custom-field joins ignore it. // $_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 returned id is what the frontend stores in RHF state and ultimately // Attachment.create requires (entity_table, entity_id); our custom-field
// sends as the field value on /api/submit. /api/submit then writes that // flow stores the file id directly in the custom column (no entity_file
// id to the activity custom field (stage-N) or to the org contact custom // linkage needed for prefill/download), but the API still mandates an
// field (Food_Co_op_Organizing.*). // 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.
// //
// Orphan files: if the user uploads and then abandons the form, the // Orphan files: civicrm_file + civicrm_entity_file rows persist if the
// civicrm_file + civicrm_entity_file rows persist with no business // user uploads and then abandons the form. Cleanup is handled by a
// reference. Cleanup is handled by a CiviCRM scheduled job (configured // CiviCRM scheduled job configured separately by the Civi admin.
// separately by the Civi admin) that prunes orphans older than ~24h.
let fileId: number; let fileId: number;
try { try {
const res = await civi3Upload<{ id: string | number }>( const res = await civi3<{ id: string | number }>("Attachment", "create", {
"Attachment", entity_table: "civicrm_contact",
"create", entity_id: Number(cid),
{ name: safeName,
entity_table: "civicrm_contact", mime_type: clientMime,
entity_id: Number(cid), content: Buffer.from(bytes).toString("base64"),
name: safeName, sequential: 1,
mime_type: clientMime, });
sequential: 1,
},
{ bytes, filename: safeName, mime: clientMime },
);
const idRaw = res.values?.[0]?.id; const idRaw = res.values?.[0]?.id;
const id = typeof idRaw === "string" ? Number(idRaw) : idRaw; const id = typeof idRaw === "string" ? Number(idRaw) : idRaw;
if (!id || !Number.isFinite(id)) { if (!id || !Number.isFinite(id)) {