Files
WebForm-mw/civi-extension/webform-mw/CRM/WebformMw/Page/Upload.php
T
Joel Brock 325615576a 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.
2026-06-10 12:28:43 -07:00

119 lines
4.2 KiB
PHP

<?php
/**
* File upload proxy.
*
* External callers (the WebForm-mw /api/upload route on Amplify) POST a
* multipart request here with a `file` part. We hand the uploaded temp
* path to APIv3 Attachment.create via `options.move-file`, which is the
* one upload pathway Civi reliably honors on this install:
*
* - APIv4 File.create + content:base64 -> 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();
}
}