diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts index 319b3d6..9d20e93 100644 --- a/app/api/upload/route.ts +++ b/app/api/upload/route.ts @@ -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 }, diff --git a/civi-extension/webform-mw/CRM/WebformMw/Page/Upload.php b/civi-extension/webform-mw/CRM/WebformMw/Page/Upload.php new file mode 100644 index 0000000..14b0e30 --- /dev/null +++ b/civi-extension/webform-mw/CRM/WebformMw/Page/Upload.php @@ -0,0 +1,118 @@ + stores base64 text on disk. + * - APIv3 Attachment.create + content:b64 -> same. + * - APIv3 Attachment.create + multipart -> /civicrm/ajax/rest doesn't + * expose $_FILES to the + * action, so "file" is + * silently ignored. + * - APIv3 Attachment.create + options.move-file -> WORKS. Civi reads + * the path, moves the file + * into civicrm.files/upload, + * writes correct bytes. + * + * Required POST fields: + * file the binary (multipart `file` part) + * entity_table e.g. "civicrm_contact" (per Attachment.create contract) + * entity_id the entity id to link to + * name (optional) file_name; defaults to the upload's name + * mime_type (optional) defaults to the upload's reported type + * + * Returns JSON: { id, name } on success, { error } with 4xx/5xx otherwise. + * + * Authorization: `access CiviCRM`. AuthX is expected to authenticate the + * Bearer + Site-Key headers WebForm-mw sends. + */ +class CRM_WebformMw_Page_Upload extends CRM_Core_Page { + + public function run() { + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { + $this->jsonError('POST required', 405); + return; + } + + if (!CRM_Core_Permission::check('access CiviCRM')) { + $this->jsonError('Permission denied', 403); + return; + } + + if (empty($_FILES['file']) || !is_array($_FILES['file'])) { + $this->jsonError('Missing file part', 400); + return; + } + $upload = $_FILES['file']; + if ((int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { + $this->jsonError('Upload failed (php error ' . (int) $upload['error'] . ')', 400); + return; + } + if (empty($upload['tmp_name']) || !is_uploaded_file($upload['tmp_name'])) { + $this->jsonError('Invalid upload tmp path', 400); + return; + } + + $entityTable = (string) ($_POST['entity_table'] ?? ''); + $entityId = (int) ($_POST['entity_id'] ?? 0); + // Whitelist entity tables to mirror the redirect route's defensive scope. + if (!in_array($entityTable, ['civicrm_contact', 'civicrm_activity'], TRUE)) { + $this->jsonError('Invalid entity_table', 400); + return; + } + if ($entityId <= 0) { + $this->jsonError('Invalid entity_id', 400); + return; + } + + $name = (string) ($_POST['name'] ?? $upload['name'] ?? 'upload'); + $mime = (string) ($_POST['mime_type'] ?? $upload['type'] ?? 'application/octet-stream'); + + try { + $result = civicrm_api3('Attachment', 'create', [ + 'entity_table' => $entityTable, + 'entity_id' => $entityId, + 'name' => $name, + 'mime_type' => $mime, + 'options' => [ + 'move-file' => $upload['tmp_name'], + ], + ]); + $fileId = NULL; + if (!empty($result['id'])) { + $fileId = (int) $result['id']; + } + elseif (!empty($result['values']) && is_array($result['values'])) { + $first = reset($result['values']); + if (!empty($first['id'])) { + $fileId = (int) $first['id']; + } + } + if (!$fileId) { + throw new Exception('Attachment.create returned no id'); + } + $this->jsonOk(['id' => $fileId, 'name' => $name]); + } + catch (Throwable $e) { + $this->jsonError('Attachment.create failed: ' . $e->getMessage(), 500); + } + } + + private function jsonOk(array $payload): void { + header('Content-Type: application/json', TRUE, 200); + echo json_encode($payload); + CRM_Utils_System::civiExit(); + } + + private function jsonError(string $message, int $status): void { + header('Content-Type: application/json', TRUE, $status); + echo json_encode(['error' => $message]); + CRM_Utils_System::civiExit(); + } + +} diff --git a/civi-extension/webform-mw/xml/Menu/webform_mw.xml b/civi-extension/webform-mw/xml/Menu/webform_mw.xml index 1c0444a..c0e7b7d 100644 --- a/civi-extension/webform-mw/xml/Menu/webform_mw.xml +++ b/civi-extension/webform-mw/xml/Menu/webform_mw.xml @@ -12,4 +12,10 @@ CRM_WebformMw_Page_File access CiviCRM + + civicrm/webform-mw/upload + WebForm-mw file upload proxy + CRM_WebformMw_Page_Upload + access CiviCRM + diff --git a/lib/civicrm.ts b/lib/civicrm.ts index c509fab..02b7d4c 100644 --- a/lib/civicrm.ts +++ b/lib/civicrm.ts @@ -244,6 +244,58 @@ export async function civi3Upload( 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( + path: string, + fields: Record, + file: { bytes: Uint8Array; filename: string; mime: string }, + opts: CiviApiOptions = {}, +): Promise { + 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 = { + "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. *