tag that loads the vendored htmx.js with an SRI * integrity attribute and crossorigin="anonymous". Returns empty string when * htmx is disabled or the vendored file is absent. * * hx-boost is intentionally left off the global document. Boost intercepts * every anchor and form, which conflicts with Cacti's existing jQuery-driven * navigation and AJAX submission model. Pages that want htmx behaviour opt in * per-element with explicit hx-* attributes. * * The integrity attribute uses the HTMX_2_0_11_SRI constant pinned to the * vendored build (see that constant for why the hash is not recomputed here). * * The src is root-absolute (prefixed with CACTI_PATH_URL) so the asset * resolves the same way on plugin pages served from subdirectories, matching * get_md5_include_js() in lib/functions.php. A document-relative src 404s * whenever the current page lives below the Cacti root. The cache-buster * query string matches that helper's shape too (path + '?' + hash, no * parameter name), and is recomputed on every call rather than cached in a * static: get_md5_include_js()/get_theme_paths() do the same, since a static * cache would leave long-lived PHP processes (FPM, script_server) serving a * stale hash after include/js/htmx.js changes. * * Two wiring pieces precede the script element: * - an htmx-config meta that disables allowEval/allowScriptTags. htmx 2.0.11 * defaults both to true, which Cacti's CSP (no unsafe-eval) forbids. The * meta is read by htmx at load, so it must appear before the script. * HTML history snapshots are disabled, and history misses reload from * Cacti so authenticated content is not retained in browser storage. * - history cache cleanup and a cache-hit guard prevent stale or externally * reenabled snapshots from bypassing that policy. The guard cancels the * HTML swap and reloads the current URL through the server. * - an htmx:configRequest listener that adds the csrf-magic token to * body-based (POST/PUT/PATCH) htmx requests. Cacti validates the * __csrf_magic field on POSTs; layout.js injects it into $.post payloads, * but htmx has no such hook, so the first hx-post would otherwise fail * CSRF validation. GET and DELETE are excluded because htmx 2.0.11 encodes * their parameters into the URL (methodsThatUseUrlParams), which would * leak the token into query strings and server logs. * * @return string */ function htmx_script_tag(): string { if (!cacti_htmx_enabled()) { return ''; } $js_path = CACTI_PATH_BASE . '/include/js/htmx.js'; if (!file_exists($js_path)) { return ''; } // md5 is a cache-buster for the asset URL only, recomputed on every call // (not cached in a static) so an htmx.js upgrade is visible without a // process restart. Integrity is the pinned constant, never derived from // the served file (see HTMX_2_0_11_SRI). $md5 = md5_file($js_path); // CACTI_PATH_URL is defined by include/global_path.php during a normal // bootstrap. Fall back to '/' when the constant is absent (lightweight // fixtures that load lib/htmx.php without the full path bootstrap). $base = defined('CACTI_PATH_URL') ? CACTI_PATH_URL : '/'; $url = $base . 'include/js/htmx.js?' . $md5; $nonce_attr = CactiSecureHeaders::getNonceAttribute(); $config_meta = "\n"; // htmx:configRequest fires before each request and bubbles to document; // listen on document so this works from the where document.body is // still null. Mirror layout.js by adding csrfMagicToken under the // __csrf_magic field that csrf-magic validates. Only body-based verbs get // the token: htmx puts GET and DELETE parameters into the URL, which would // leak it (see the function doc). $csrf_wiring = "\n"; $tag = "\n"; // Both wiring pieces precede the script element (see the function doc): // the config meta must exist before htmx reads it at load, and registering // the listener first means no configRequest can ever fire without it. return $config_meta . $csrf_wiring . $tag; }