diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1749ec0..9df2dde 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,6 +86,22 @@ jobs: - name: Install npm dependencies run: npm ci + - name: Verify the vendored external-media artifact + # eXeLearning core is canonical (exelearning/exelearning ADR-2199-12): this repo + # holds the BYTES and verifies them against the manifest core published, rather + # than a copy of the logic that could drift. --build-hash makes it a PROVENANCE + # check and not merely an integrity one: without it a locally rebuilt-and-resigned + # copy would pass. + # + # If this fails, a vendored file was edited here. Fix it in eXeLearning core and + # re-vendor; a local patch is invisible upstream and is overwritten next time. + run: | + # The expected hash is PINNED HERE, deliberately, and not read from the vendored + # manifest. A hash taken from the copy under test cannot say anything about that + # copy: file, digest and buildHash can all be rewritten together, and a check that + # trusted them would pass a consistent forgery. This line is the out-of-band half. + # Bump it in the same commit that re-vendors the artifact. + node assets/js/exe_external_media/verify.mjs assets/js/exe_external_media --build-hash "43c776daa551fd770b6dade520629362e5a8ca8745e4245c359447c4689b34d6" - name: Install gettext run: sudo apt-get install -y gettext diff --git a/.gitignore b/.gitignore index 12a637d..90261d5 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,7 @@ public/style/workarea/*.css.map # Test results test-results/ test-results/* +playwright-report/ # Built static editor - download from releases or build with `make build-editor` dist/static/ diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist index fbfc57f..93fd364 100644 --- a/.phpcs.xml.dist +++ b/.phpcs.xml.dist @@ -17,6 +17,10 @@ ^wp-content/uploads/exelearning/* /bin/* + + /artifacts/* diff --git a/README.md b/README.md index 77a163d..ed331ed 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,18 @@ EXELEARNING_EDITOR_REF=my-feature EXELEARNING_EDITOR_REF_TYPE=branch make build- > **Important:** For production use, always install an official release from [Releases](https://github.com/exelearning/wp-exelearning/releases): release packages include the embedded editor pre-built under `dist/static/`, and that bundle is the only editor the plugin ever uses. The plugin never downloads editor code at runtime, and administrators cannot update the editor independently of the plugin — updating the editor means updating the plugin (a new plugin release is published automatically for every editor release). Source checkouts do not contain `dist/static/`; build it with `make build-editor` as shown above. See [ADR-72-01](docs/architecture/adr/ADR-72-01-bundle-editor-exclusively-in-release-packages.md). +### Server configuration (nginx) + +The live editor **preview** requires **pretty permalinks** (Settings → Permalinks; any structure other than *Plain*) — under plain permalinks the plugin fails closed and shows an admin notice. + +On **nginx**, also block direct web access to the ephemeral preview session store: the plugin serves those bytes through an opaque-origin capability URL with a sandbox CSP, and unlike Apache (`.htaccess`) nginx will otherwise serve the materialized author HTML same-origin without that CSP. Include the shipped snippet from your `server { … }` block: + +```nginx +include /path/to/wp-content/plugins/exelearning/nginx-exelearning-preview.conf; +``` + +See [`nginx-exelearning-preview.conf`](nginx-exelearning-preview.conf) and [`docs/preview-serving-contract.md`](docs/preview-serving-contract.md) for details. + ## Usage ### Uploading ELPX Files @@ -103,6 +115,30 @@ Administrators can upload eXeLearning style packages and control which styles th Uploaded ZIPs are validated against path traversal, absolute paths, oversize archives (default 20 MB, filterable via `exelearning_styles_max_zip_size`), and a strict file-extension allow-list. +## External embeds in secure mode + +In secure mode the `.elpx` content runs in a sandboxed, opaque-origin iframe. That +opaque origin propagates to any nested iframe, so cross-origin video players and PDF +viewers render blank. To keep them working, whitelisted video embeds (YouTube and +Vimeo hosts), any cross-origin `https` `.pdf`, and the package's own local PDFs are +*promoted* to the trusted parent page and rendered inline on top of the content. + +Two cooperating scripts make this work: + +- `assets/js/exe-embed-shim.js` runs inside the content iframe, replaces each + promotable iframe with a same-size placeholder, and `postMessage`s its geometry + and URL to the parent. +- `assets/js/exe-embed-relay.js` runs on the host page, validates each reported URL + against the whitelist, rebuilds the canonical player URL, and overlays the real + player exactly over the placeholder. + +A static Firefox end-to-end test exercises the real shim and relay against a +self-contained harness (no WordPress runtime needed): + +```bash +npm run test:e2e:embed +``` + ## Developer hooks The plugin exposes a set of WordPress actions and filters (all prefixed with diff --git a/admin/views/editor-bootstrap.php b/admin/views/editor-bootstrap.php index 81b62a2..bfc21ab 100644 --- a/admin/views/editor-bootstrap.php +++ b/admin/views/editor-bootstrap.php @@ -108,6 +108,21 @@ 'fallbackTheme' => 'base', ); +// Opaque snapshot editor-preview (capability contract v1). The editor POSTs a +// whole-project ZIP snapshot to the authenticated management route and loads the +// result from an authless, opaque-origin capability URL. REST URLs resolve under +// both pretty and plain permalinks. wp_json_encode() output is valid JS syntax. +$exelearning_preview_delete_url = rest_url( + 'exelearning/v1/preview-session/' . $exelearning_attachment_id . '/__PREVIEW_ID__' +); +$exelearning_preview_snapshot = array( + 'managementUrl' => rest_url( 'exelearning/v1/preview-session/' . $exelearning_attachment_id ), + 'servingBaseUrl' => rest_url( 'exelearning/v1/preview/' ), + 'deleteUrlTemplate' => str_replace( '__PREVIEW_ID__', '{previewId}', $exelearning_preview_delete_url ), + 'managementHeaders' => array( 'X-WP-Nonce' => $exelearning_nonce ), +); +$exelearning_preview_snapshot_js = "\n previewSnapshot: " . wp_json_encode( $exelearning_preview_snapshot ) . ','; + // Inject WordPress configuration BEFORE the closing tag. // phpcs:disable WordPress.WP.EnqueuedResources.NonEnqueuedScript -- Standalone HTML page output, not a WordPress template. $exelearning_wp_config_script = sprintf( @@ -197,15 +212,18 @@ enumerable: true, fileMenu: true, saveButton: true, userMenu: true, - }, + },%s }; - // TODO: Remove when editor ResourceFetcher handles 404 gracefully. - // Patch fetch and jQuery AJAX to handle CSS/idevices 404s without breaking. + // Embedded-editor shims: hide the chrome the host owns and soften CSS / + // idevice 404s so a missing optional asset never breaks boot. The live + // preview travels as an opaque snapshot capability (see previewSnapshot + // above), never a Service Worker on the WordPress origin — so there is no + // preview-sw / /viewer/ wiring here. + // TODO: Remove the 404 shim when the editor ResourceFetcher handles 404 + // gracefully. (function() { var editorBaseUrl = (window.__WP_EXE_CONFIG__ && window.__WP_EXE_CONFIG__.editorBaseUrl) || ""; - var editorBasePathname = ""; - var originalServiceWorker = navigator.serviceWorker || null; var forceHideSelectors = [ "#dropdownFile", "#head-top-save-button", @@ -215,12 +233,6 @@ enumerable: true, "#mobile-navbar-button-openuserodefiles" ]; - try { - editorBasePathname = editorBaseUrl ? new URL(editorBaseUrl, window.location.origin).pathname : ""; - } catch (e) { - editorBasePathname = ""; - } - function forceHideEmbeddedUi() { for (var i = 0; i < forceHideSelectors.length; i += 1) { var nodes = document.querySelectorAll(forceHideSelectors[i]); @@ -240,94 +252,20 @@ function forceHideEmbeddedUi() { } } - function normalizePreviewIframeSrc(url) { - if (!url || !editorBaseUrl) { - return url; - } - - var baseNoSlash = editorBaseUrl.replace(/\/$/, ""); - var raw = url; - - try { - if (raw.startsWith("http://") || raw.startsWith("https://")) { - raw = new URL(raw).pathname; - } - } catch (e) {} - - if (raw.indexOf("/wp-admin/admin.php/viewer/") === 0) { - return baseNoSlash + "/viewer/" + raw.substring("/wp-admin/admin.php/viewer/".length); - } - if (raw.indexOf("/viewer/") === 0) { - return baseNoSlash + raw; - } - if (raw.indexOf("viewer/") === 0) { - return baseNoSlash + "/" + raw; - } - - return url; - } - - function ensurePreviewIframeSrc() { - var previewIframe = document.getElementById("preview-iframe"); - if (!previewIframe) { - return; - } - - var currentSrc = previewIframe.getAttribute("src") || previewIframe.src || ""; - var fixedSrc = normalizePreviewIframeSrc(currentSrc); - if (fixedSrc && fixedSrc !== currentSrc) { - previewIframe.setAttribute("src", fixedSrc); - } - } - if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", forceHideEmbeddedUi); - document.addEventListener("DOMContentLoaded", ensurePreviewIframeSrc); } else { forceHideEmbeddedUi(); - ensurePreviewIframeSrc(); } var hideObserver = new MutationObserver(function() { forceHideEmbeddedUi(); - ensurePreviewIframeSrc(); }); hideObserver.observe(document.documentElement || document.body, { childList: true, - subtree: true, - attributes: true, - attributeFilter: ["src"] + subtree: true }); - // Fix preview service worker paths in WP mode. - if (originalServiceWorker && editorBasePathname) { - var registerOriginal = originalServiceWorker.register.bind(originalServiceWorker); - var getRegistrationOriginal = originalServiceWorker.getRegistration.bind(originalServiceWorker); - var fixedSwPath = editorBasePathname.replace(/\/$/, "") + "/preview-sw.js"; - var fixedScope = editorBasePathname.replace(/\/$/, "") + "/viewer/"; - - originalServiceWorker.register = function(scriptURL, options) { - var nextScript = scriptURL; - var nextOptions = options || {}; - if (typeof nextScript === "string" && nextScript.indexOf("preview-sw.js") !== -1) { - nextScript = fixedSwPath; - nextOptions = Object.assign({}, nextOptions, { scope: fixedScope }); - } - return registerOriginal(nextScript, nextOptions); - }; - - originalServiceWorker.getRegistration = function(clientURL) { - var nextClientUrl = clientURL; - if ( - !nextClientUrl || - (typeof nextClientUrl === "string" && nextClientUrl.indexOf("/wp-admin/") === 0) - ) { - nextClientUrl = fixedScope; - } - return getRegistrationOriginal(nextClientUrl); - }; - } - function normalizeEditorAssetUrl(url) { if (!url || typeof url !== "string" || !editorBaseUrl) { return url; @@ -479,6 +417,7 @@ function normalizeEditorAssetUrl(url) { wp_json_encode( $exelearning_editor_base_url ), wp_json_encode( $exelearning_i18n ), wp_json_encode( $exelearning_theme_registry_override ), + $exelearning_preview_snapshot_js, esc_url( $exelearning_plugin_assets_url ) ); // phpcs:enable WordPress.WP.EnqueuedResources.NonEnqueuedScript @@ -535,13 +474,29 @@ function normalizeEditorAssetUrl(url) { // Insert config script and styles before . $exelearning_template = str_replace( '', $exelearning_wp_config_script . $exelearning_page_styles . '', $exelearning_template ); -// Add tag to set the base URL for all relative paths. -// This ensures paths like "files/perm/..." resolve to the static editor directory. -// The word boundary matters: the editor's own markup contains -// `