From b65bc6d0e08756f3ef155b19384f1b381cf59358 Mon Sep 17 00:00:00 2001 From: Joel Brock Date: Wed, 10 Jun 2026 10:47:34 -0700 Subject: [PATCH] File redirect route: mint fcs JWT in extension, link from staff report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Path 1 (APIv4 Attachment.get + select url) shipped but didn't fix the Firebase\JWT decode crash — the deployed Civi version either omits `url` from Attachment.get or returns it without the fcs param. Falling back to the bare /civicrm/file?id=X URL hits the same JWT null crash. Path 2: route file clicks through a tiny redirect endpoint in the Civi extension instead. The extension runs PHP on Civi, has access to the crypto.jwt service, and mints the same shape of token Civi's own file URL builder uses ({exp, "civi.file": }) before 302-redirecting to the canonical /civicrm/file URL. Civi extension changes: - New CRM/WebformMw/Page/File.php — resolves eid from civicrm_entity_file if not supplied, signs a 7-day JWT via Civi::service('crypto.jwt'), redirects. - xml/Menu/webform_mw.xml — registers civicrm/webform-mw/file. Requires `access CiviCRM` (the user is already authenticated in the parent Civi tab when they click the link). Frontend (StaffReportView.tsx, FieldValue): - When Attachment.get's url is missing, fall back to the new extension route instead of bare /civicrm/file. Attachment.get's url remains the fast path when present. Deploy: admin needs to push the updated extension files to the Civi server, then Disable/Enable webform-mw (or cv flush) so the new menu route registers in civicrm_menu. --- .../webform-mw/CRM/WebformMw/Page/File.php | 65 +++++++++++++++++++ .../webform-mw/xml/Menu/webform_mw.xml | 6 ++ components/StaffReportView.tsx | 9 +-- 3 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 civi-extension/webform-mw/CRM/WebformMw/Page/File.php diff --git a/civi-extension/webform-mw/CRM/WebformMw/Page/File.php b/civi-extension/webform-mw/CRM/WebformMw/Page/File.php new file mode 100644 index 0000000..fdc6421 --- /dev/null +++ b/civi-extension/webform-mw/CRM/WebformMw/Page/File.php @@ -0,0 +1,65 @@ +` and rely on us to mint the `fcs` JWT that + * Civi's `/civicrm/file` handler requires. The JWT is signed with the + * site's crypto key, which we have here because we're running inside + * CiviCRM; the Next.js side doesn't. + * + * Permission: `access CiviCRM`. The user is already authenticated in the + * parent Civi tab when they click a link in the iframe; their session + * cookie travels with the new-tab navigation. + * + * URL shape: + * /civicrm/webform-mw/file?id=[&eid=] + * + * Behavior: + * - Resolve `eid` from civicrm_entity_file if not provided. + * - Mint a short-lived JWT with payload {exp, civi.file: } + * matching Civi's own /civicrm/file token format. + * - 302-redirect to /civicrm/file?reset=1&id=...&eid=...&fcs=. + */ +class CRM_WebformMw_Page_File extends CRM_Core_Page { + + public function run() { + $fileId = (int) CRM_Utils_Request::retrieve('id', 'Positive', $this, TRUE); + $eid = (int) CRM_Utils_Request::retrieve('eid', 'Positive', $this, FALSE, 0); + + // Resolve eid from the entity_file join if the caller didn't supply + // one. Any linked entity works for URL-fingerprint purposes; the JWT + // we mint below is what Civi actually authenticates on. + if (!$eid) { + $dao = CRM_Core_DAO::executeQuery( + "SELECT entity_id FROM civicrm_entity_file WHERE file_id = %1 LIMIT 1", + [1 => [$fileId, 'Positive']] + ); + if ($dao->fetch()) { + $eid = (int) $dao->entity_id; + } + } + + // Mint the fcs JWT. Payload matches the structure Civi's own file + // URL builder emits: {exp, "civi.file": ""}. One-week lifetime — + // these links are typically clicked seconds after the report renders, + // but the staff report can be left open for a while in a Civi tab. + $payload = [ + 'exp' => time() + 60 * 60 * 24 * 7, + 'civi.file' => (string) $fileId, + ]; + $fcs = \Civi::service('crypto.jwt')->encode($payload); + + $url = CRM_Utils_System::url( + 'civicrm/file', + "reset=1&id={$fileId}&eid={$eid}&fcs=" . urlencode($fcs), + FALSE, + NULL, + FALSE, + TRUE + ); + CRM_Utils_System::redirect($url); + } + +} diff --git a/civi-extension/webform-mw/xml/Menu/webform_mw.xml b/civi-extension/webform-mw/xml/Menu/webform_mw.xml index a9c1494..1c0444a 100644 --- a/civi-extension/webform-mw/xml/Menu/webform_mw.xml +++ b/civi-extension/webform-mw/xml/Menu/webform_mw.xml @@ -6,4 +6,10 @@ CRM_WebformMw_Page_Tab access CiviCRM + + civicrm/webform-mw/file + WebForm-mw file redirect + CRM_WebformMw_Page_File + access CiviCRM + diff --git a/components/StaffReportView.tsx b/components/StaffReportView.tsx index 037110c..dae58a0 100644 --- a/components/StaffReportView.tsx +++ b/components/StaffReportView.tsx @@ -460,16 +460,17 @@ function FieldValue({ const id = String(v.id); const name = v.file_name ?? `file-${id}`; // Prefer the Civi-signed URL (carries the fcs JWT) returned by - // Attachment.get; Civi's file handler crashes on a null fcs decode if we - // hit /civicrm/file?id=X bare. Fall back to a bare URL only if signed - // URLs weren't available (e.g. older Civi without `url` on Attachment). + // Attachment.get. If absent, fall back to the WebForm-mw Civi extension's + // file-redirect route — it mints the fcs server-side and 302s to the + // real /civicrm/file URL. (Hitting /civicrm/file?id=X bare crashes Civi + // on a null fcs JWT decode.) let href = "#"; if (v.url) { href = v.url.startsWith("http") ? v.url : `${civiBaseUrl}${v.url.startsWith("/") ? "" : "/"}${v.url}`; } else if (civiBaseUrl) { - href = `${civiBaseUrl}/civicrm/file?reset=1&id=${encodeURIComponent(id)}`; + href = `${civiBaseUrl}/civicrm/webform-mw/file?id=${encodeURIComponent(id)}`; } return (