diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b19f69..5fb7bbe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,6 +90,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/AGENTS.md b/AGENTS.md index 05be42a..6cadbcd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,9 @@ Packages are plain WordPress **attachments** — there is no custom post type. | `/create` | POST | `upload_files` | | `/elp-data/{id}` | GET | `edit_post` | | `/reprocess/{id}` | POST | `edit_post` | +| `/preview-session/{id}` | POST | `upload_files` + `edit_post` (editor preview snapshot) | +| `/preview-session/{id}/{previewId}` | DELETE | `upload_files` + `edit_post` | +| `/preview/{previewId}/{file}` | GET | public (unguessable UUID, sandbox CSP, 30-minute idle TTL) | ### Data @@ -49,17 +52,20 @@ Packages are plain WordPress **attachments** — there is no custom post type. `_exelearning_obsolete_hash` (retired hashes kept as redirect aliases). - Options: `exelearning_db_version`, `exelearning_proxy_assets`, `exelearning_styles_registry`, `exelearning_styles_block_import`, - `exelearning_disabled_styles`; `uninstall.php` deletes them and keeps user content. + `exelearning_disabled_styles`, `exelearning_embed_mode`, + `exelearning_iframe_sandbox_mode`; `uninstall.php` deletes them and keeps user content. ## Project boundaries - Archive processing, styles and content delivery go through the existing file-service, style-service and content-proxy classes. Preserve capability, nonce, path-validation and content-delivery boundaries when changing them. - Package HTML is untrusted author content served on the site origin. Do not - drop `allow-same-origin` from its iframes piecemeal: packages need storage and - cookies, embedded video needs the parent relay, and Playground cannot serve - opaque-origin subframes. The opaque-origin viewer lands as a whole in #56. + Package HTML is untrusted author content. `ExeLearning_Iframe_Sandbox` is the + single source of the iframe `sandbox` tokens and content CSP: package iframes + never get `allow-same-origin`, so they run in an opaque origin, and external + video plays through the parent embed relay (`assets/js/exe-embed-relay.js`). + `EXELEARNING_UNSAFE_LEGACY_IFRAME` is a dev-only escape hatch for Playground, + whose Service Worker cannot serve opaque subframes; never set it in production. - Update `docs/SHORTCODES.md` with shortcode attributes and `docs/HOOKS.md` with public actions/filters in the same change. - The block uses Block API version 3 (change 89), which WordPress 6.1 still loads. diff --git a/README.md b/README.md index 33078e9..dadbf68 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 907b258..70f4a5e 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. $exelearning_wp_config_script = sprintf( ' @@ -194,15 +209,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", @@ -212,12 +230,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]); @@ -237,94 +249,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; @@ -473,7 +411,8 @@ function normalizeEditorAssetUrl(url) { $exelearning_user_id, wp_json_encode( $exelearning_editor_base_url ), wp_json_encode( $exelearning_i18n ), - wp_json_encode( $exelearning_theme_registry_override ) + wp_json_encode( $exelearning_theme_registry_override ), + $exelearning_preview_snapshot_js ); // WordPress-specific styles. @@ -536,13 +475,32 @@ function normalizeEditorAssetUrl(url) { $exelearning_integration_assets = ob_get_clean(); $exelearning_template = str_replace( '', $exelearning_integration_assets . '', $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 -// `