diff --git a/.gitignore b/.gitignore
index a4dca9c..fb06583 100644
--- a/.gitignore
+++ b/.gitignore
@@ -54,6 +54,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 5273f60..d96a67d 100644
--- a/.phpcs.xml.dist
+++ b/.phpcs.xml.dist
@@ -9,9 +9,16 @@
/tests/*/includes/vendor/*/dist/*
- /exelearning/*
+
+ */wp-content/uploads/exelearning/*/bin/*
+
+ /artifacts/*
diff --git a/README.md b/README.md
index b17cc3f..83a410f 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-0002](docs/architecture/adr/ADR-0002-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 492f597..814b1a7 100644
--- a/admin/views/editor-bootstrap.php
+++ b/admin/views/editor-bootstrap.php
@@ -111,6 +111,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(
@@ -200,15 +215,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",
@@ -218,12 +236,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]);
@@ -243,94 +255,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;
@@ -482,6 +420,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
@@ -538,10 +477,23 @@ 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.
+// Add tag to set the base URL for all relative paths, and a first-thing
+// Service Worker hygiene script that purges caches left by an earlier editor
+// build (stale assets could otherwise drive the preview into a refresh loop).
+// It does NOT block registration: the trust-boundary editor registers its own
+// /viewer/ preview worker, which serves only DOMPurify-sanitized ("filtered")
+// content from a real same-origin URL — required so whitelisted external videos
+// play inline (see ExeLearning_Editor::purge_stale_editor_caches_script). Both are
+// injected right after
, before any editor script runs.
+// phpcs:disable WordPress.WP.EnqueuedResources.NonEnqueuedScript -- Standalone HTML page output, not a WordPress template.
+$exelearning_cache_purge_tag = '';
+// phpcs:enable WordPress.WP.EnqueuedResources.NonEnqueuedScript
$exelearning_base_tag = sprintf( '', esc_url( $exelearning_editor_base_url ) );
-$exelearning_template = preg_replace( '/(]*>)/i', '$1' . $exelearning_base_tag, $exelearning_template );
+$exelearning_template = preg_replace(
+ '/(]*>)/i',
+ '$1' . $exelearning_cache_purge_tag . $exelearning_base_tag,
+ $exelearning_template
+);
// Fix asset paths: Replace relative paths with absolute plugin paths.
// The static build uses relative paths like "./app/", we need absolute paths.
diff --git a/assets/css/exelearning.css b/assets/css/exelearning.css
index 1285c74..0ab6441 100644
--- a/assets/css/exelearning.css
+++ b/assets/css/exelearning.css
@@ -291,6 +291,56 @@
max-width: 100%;
}
+/* Loading state for the embedded package.
+ The package is fetched, parsed and laid out inside the iframe before anything
+ paints, so without this the visitor stares at an empty frame. The spinner is
+ hidden unless the enqueued loader adds `is-loading`, so with JS disabled
+ nothing is shown that JS would never be able to clear. */
+.exelearning-embed-loader {
+ position: relative;
+}
+
+.exelearning-embed-loader__spinner {
+ display: none;
+ position: absolute;
+ inset: 0;
+ align-items: center;
+ justify-content: center;
+ gap: 0.6em;
+ background: #fff;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ color: #50575e;
+ font-size: 0.875rem;
+}
+
+/* Only the visibility depends on the state. */
+.exelearning-embed-loader.is-loading .exelearning-embed-loader__spinner {
+ display: flex;
+}
+
+.exelearning-embed-loader__spinner::before {
+ content: "";
+ width: 1.25em;
+ height: 1.25em;
+ border: 2px solid currentColor;
+ border-top-color: transparent;
+ border-radius: 50%;
+ animation: exelearning-embed-spin 0.8s linear infinite;
+}
+
+@keyframes exelearning-embed-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .exelearning-embed-loader__spinner::before {
+ animation-duration: 3s;
+ }
+}
+
.exelearning-screenshot-img {
display: block;
width: 100%;
diff --git a/assets/js/exe-embed-relay.js b/assets/js/exe-embed-relay.js
new file mode 100644
index 0000000..9a442b8
--- /dev/null
+++ b/assets/js/exe-embed-relay.js
@@ -0,0 +1,635 @@
+/**
+ * eXeLearning external-embed relay (runs on the HOST page, a trusted context).
+ *
+ * Companion to exe-embed-shim.js. Listens for geometry reports posted by the
+ * opaque content iframe(s), validates each embed URL, and renders the real
+ * player as an inline overlay positioned exactly over the placeholder inside the
+ * content iframe.
+ *
+ * Trust model (DEC-0061): the promoted player is rendered cross-origin and
+ * SANDBOXED, so the same-origin policy isolates it from this host page (it cannot
+ * read the DOM, cookies or session). Two modes:
+ * - 'open' (default): promote any iframe whose src is https AND cross-origin to
+ * the host (rejecting same-origin, sub/superdomains of the host, IP/loopback/
+ * local hosts and userinfo). No host list. The host is irrelevant to escape;
+ * the residual is phishing/tracking, bounded to the content's own box.
+ * - 'strict': only a maintained host allowlist with per-provider canonical-URL
+ * reconstruction, for high-security deployments.
+ * "Any https .pdf" is always allowed (same-origin only for this package's own files).
+ *
+ * Messages are authenticated by event.source (the opaque origin has no usable
+ * event.origin); the host page is same-origin, so window.location.origin is the
+ * host origin and the cross-origin check correctly rejects the proxied content.
+ *
+ * Config: window.ExeEmbedRelayConfig = { mode: 'open'|'strict', whitelist: [...] }.
+ *
+ * MIRROR of the canonical eXeLearning embedder source in eXe core
+ * (public/app/common/exe_embed_bridge/exe_embed_relay.js). Keep the validate()/makePlayer()/sync()
+ * logic identical to core; only the export wrapper differs (this one is an auto-running
+ * IIFE). scripts/check-embed-sync.mjs in eXe core flags drift.
+ *
+ * @package Exelearning
+ */
+( function () {
+ 'use strict';
+
+ /**
+ * Build a host lookup map from a whitelist array (lowercased). Used by 'strict' mode.
+ *
+ * @param {string[]} list The whitelist of hostnames.
+ * @return {Object} Map of lowercase host => true.
+ */
+ function buildWhitelist( list ) {
+ var map = {};
+ ( list || [] ).forEach( function ( host ) {
+ map[ String( host ).toLowerCase() ] = true;
+ } );
+ return map;
+ }
+
+ /**
+ * Directory portion of the content iframe src (everything up to the last '/').
+ *
+ * @param {string} src The content iframe src.
+ * @return {string} The base directory URL, or '' if it cannot be parsed.
+ */
+ function contentDir( src ) {
+ try {
+ return new URL( src, window.location.href ).href.replace( /[^/]*([?#].*)?$/, '' );
+ } catch ( e ) {
+ return '';
+ }
+ }
+
+ /**
+ * Long hex token shared by the content URL and its assets (null when there is
+ * none, e.g. content URLs that use numeric ids).
+ *
+ * @param {string} src The content iframe src.
+ * @return {?string} The hash, or null if none is found.
+ */
+ function packageId( src ) {
+ var match = String( src ).match( /[a-f0-9]{12,}/i );
+ return match ? match[ 0 ] : null;
+ }
+
+ /**
+ * Whether a same-origin URL is one of this package's own extracted files: under
+ * the content's own directory, or carrying the package hash as a path segment.
+ *
+ * @param {URL} url The candidate URL (already same-origin).
+ * @param {string} contentSrc The content iframe src.
+ * @return {boolean} True when the URL belongs to this package.
+ */
+ function isSameOriginPackageFile( url, contentSrc ) {
+ var dir = contentDir( contentSrc );
+ if ( dir && 0 === url.href.indexOf( dir ) ) {
+ return true;
+ }
+ var id = packageId( contentSrc );
+ return !! ( id && url.pathname.indexOf( '/' + id + '/' ) !== -1 );
+ }
+
+ /**
+ * Whether a host is an IP literal (v4/v6) or a loopback/local name. Such hosts are
+ * cross-origin to the host yet target the machine/internal network, so they are
+ * rejected even though SOP would isolate them.
+ *
+ * @param {string} host Lowercased URL.hostname.
+ * @return {boolean} True when the host is an IP or local name.
+ */
+ function isIpOrLocalHost( host ) {
+ if ( ! host ) {
+ return true;
+ }
+ if ( 'localhost' === host || /\.localhost$/.test( host ) || /\.local$/.test( host ) ) {
+ return true;
+ }
+ if ( '[' === host.charAt( 0 ) || host.indexOf( ':' ) !== -1 ) {
+ return true; // IPv6 (bracketed).
+ }
+ if ( /^\d{1,3}(\.\d{1,3}){3}$/.test( host ) ) {
+ return true; // Any IPv4 literal.
+ }
+ return false;
+ }
+
+ /**
+ * Lowercase a hostname and strip a single trailing dot. 'host.example.org.' (the
+ * FQDN-root form) resolves to the same vhost as 'host.example.org' but compares
+ * unequal as a raw string, so without this it would slip past the same-origin /
+ * related-to-host gate below and be promoted as a cross-origin player.
+ *
+ * @param {string} host The hostname to normalise.
+ * @return {string} The lowercased hostname without a trailing dot.
+ */
+ function normalizeHost( host ) {
+ return ( host || '' ).toLowerCase().replace( /\.$/, '' );
+ }
+
+ /**
+ * Whether a host equals, is a subdomain of, or is a superdomain of the host page's
+ * host (dotted boundary so 'evil-host.example' does not match 'host.example'). Such
+ * hosts may share the host page's cookies, so they are rejected. Both sides are
+ * normalised so the trailing-dot FQDN-root form cannot evade the comparison.
+ *
+ * @param {string} host The candidate host.
+ * @param {string} lmsHost The host page's host.
+ * @return {boolean} True when the host is related to the host page.
+ */
+ function isRelatedToLms( host, lmsHost ) {
+ host = normalizeHost( host );
+ lmsHost = normalizeHost( lmsHost );
+ if ( ! lmsHost ) {
+ return false;
+ }
+ return host === lmsHost || host.endsWith( '.' + lmsHost ) || lmsHost.endsWith( '.' + host );
+ }
+
+ /**
+ * The structural invariant: an https URL cross-origin to the host page and not
+ * pointing at a sub/superdomain, an IP/loopback/local host, or carrying userinfo.
+ * This is the only attacker-influenced gate in 'open' mode, and it is what makes
+ * the sandboxed player's allow-same-origin safe (the embed keeps ITS OWN origin,
+ * isolated by SOP).
+ *
+ * @param {URL} url The candidate URL.
+ * @return {boolean} True when the URL is a safe cross-origin https embed.
+ */
+ function isCrossOriginHttps( url ) {
+ if ( 'https:' !== url.protocol ) {
+ return false;
+ }
+ if ( url.username || url.password ) {
+ return false;
+ }
+ if ( url.origin === window.location.origin ) {
+ return false;
+ }
+ var host = normalizeHost( url.hostname );
+ if ( isIpOrLocalHost( host ) ) {
+ return false;
+ }
+ var lmshost = ( window.location && window.location.hostname ) ? window.location.hostname : '';
+ if ( isRelatedToLms( host, lmshost ) ) {
+ return false;
+ }
+ return true;
+ }
+
+ // Provider templates for the id-only channel (DEC-0067): the parent rebuilds the canonical
+ // embed URL from {provider, objectId} reported by the shim, re-checking the object id
+ // against a strict per-provider regex so it cannot carry a path/query/fragment and escape
+ // the template (e.g. '../../x' or 'a/b?c'). The reconstructed URL still runs through
+ // validate() (structural invariant / strict whitelist), so this narrows the surface for
+ // recognised providers; it is not, by itself, the trust gate.
+ var PROVIDER_TEMPLATES = {
+ youtube: { re: /^[A-Za-z0-9_-]{6,}$/, build: function ( id ) { return 'https://www.youtube-nocookie.com/embed/' + id; } },
+ vimeo: { re: /^[0-9]+$/, build: function ( id ) { return 'https://player.vimeo.com/video/' + id; } },
+ dailymotion: { re: /^[A-Za-z0-9]{5,}$/, build: function ( id ) { return 'https://www.dailymotion.com/embed/video/' + id; } },
+ 'mediateca-madrid': { re: /^[A-Za-z0-9]{8,}$/, build: function ( id ) { return 'https://mediateca.educa.madrid.org/video/' + id + '/fs'; } }
+ };
+
+ /**
+ * Rebuild the canonical embed URL for a recognised provider from its object id, or null
+ * if the provider is unknown or the id is not the exact shape the template expects.
+ *
+ * @param {string} provider The provider key reported by the shim.
+ * @param {string} objectId The provider object id reported by the shim.
+ * @return {?string} The canonical embed URL, or null.
+ */
+ function reconstructProvider( provider, objectId ) {
+ var t = PROVIDER_TEMPLATES[ provider ];
+ if ( ! t || typeof objectId !== 'string' || ! t.re.test( objectId ) ) {
+ return null;
+ }
+ return t.build( objectId );
+ }
+
+ /**
+ * Validate an embed URL. Returns { url, kind ('video'|'pdf'), sameorigin? } or null.
+ *
+ * @param {string} raw The reported (absolute) embed URL.
+ * @param {string} contentSrc The src of the content iframe that reported it.
+ * @param {Object} opts { strict: boolean, whitelist: Object }.
+ * @return {?Object} The validated result, or null.
+ */
+ function validate( raw, contentSrc, opts ) {
+ opts = opts || {};
+ var url;
+ try {
+ // Parse as an ABSOLUTE URL (the shim always reports absolute). No base:
+ // a relative/scheme-relative value would otherwise inherit the host origin
+ // and pass as same-origin -- here it throws and is rejected instead.
+ url = new URL( raw );
+ } catch ( e ) {
+ return null;
+ }
+ if ( url.username || url.password ) {
+ return null; // Reject userinfo, e.g. https://evil.com@youtube.com/.
+ }
+ var host = url.hostname.toLowerCase();
+
+ // PDFs: any cross-origin https .pdf, or a same-origin file that belongs to this
+ // package (served as application/pdf + nosniff, never executable HTML).
+ if ( /\.pdf$/i.test( url.pathname ) ) {
+ if ( url.origin === window.location.origin ) {
+ return isSameOriginPackageFile( url, contentSrc ) ? { url: url.href, kind: 'pdf', sameorigin: true } : null;
+ }
+ return isCrossOriginHttps( url ) ? { url: url.href, kind: 'pdf' } : null;
+ }
+
+ // Strict mode: maintained host allowlist + per-provider canonical reconstruction.
+ if ( opts.strict ) {
+ var whitelist = opts.whitelist || {};
+ if ( whitelist[ host ] && 'https:' === url.protocol ) {
+ var m;
+ if ( host.indexOf( 'youtube' ) !== -1 ) {
+ m = url.pathname.match( /^\/embed\/([A-Za-z0-9_-]{6,})$/ );
+ return m ? { url: 'https://www.youtube-nocookie.com/embed/' + m[ 1 ], kind: 'video' } : null;
+ }
+ if ( host.indexOf( 'vimeo' ) !== -1 ) {
+ m = url.pathname.match( /^\/video\/([0-9]+)$/ );
+ return m ? { url: 'https://player.vimeo.com/video/' + m[ 1 ], kind: 'video' } : null;
+ }
+ if ( host.indexOf( 'dailymotion' ) !== -1 ) {
+ m = url.pathname.match( /^\/embed\/video\/([A-Za-z0-9]{5,})$/ );
+ return m ? { url: 'https://www.dailymotion.com/embed/video/' + m[ 1 ], kind: 'video' } : null;
+ }
+ if ( 'mediateca.educa.madrid.org' === host ) {
+ m = url.pathname.match( /^\/video\/([A-Za-z0-9]{8,})(?:\/fs)?$/ );
+ return m ? { url: 'https://mediateca.educa.madrid.org/video/' + m[ 1 ] + '/fs', kind: 'video' } : null;
+ }
+ }
+ return null;
+ }
+
+ // Open mode (default): any cross-origin https iframe is a video embed.
+ return isCrossOriginHttps( url ) ? { url: url.href, kind: 'video' } : null;
+ }
+
+ /**
+ * Create a SANDBOXED player iframe for a validated embed. The video player gets
+ * allow-same-origin so the cross-origin provider keeps its own origin and renders,
+ * while NO allow-top-navigation/allow-modals stops a hostile embed from redirecting
+ * the host tab or spamming dialogs. A same-origin package PDF is left unsandboxed (the
+ * browser PDF viewer needs it); a CROSS-ORIGIN PDF is sandboxed with allow-same-origin
+ * only (no allow-scripts, no allow-top-navigation) so it cannot redirect the host tab.
+ *
+ * @param {Object} result { url, kind, sameorigin? } from validate().
+ * @return {HTMLIFrameElement} The configured player iframe.
+ */
+ function makePlayer( result ) {
+ var frame = document.createElement( 'iframe' );
+ frame.style.cssText = 'position:absolute;border:0;pointer-events:auto;';
+ // Mark as a player so it is never mistaken for a content source (message auth).
+ frame.setAttribute( 'data-exe-embed-player', '1' );
+ if ( 'video' === result.kind ) {
+ frame.setAttribute( 'sandbox', 'allow-scripts allow-same-origin allow-popups allow-forms allow-presentation' );
+ frame.setAttribute( 'allow', 'autoplay; encrypted-media; fullscreen; picture-in-picture; clipboard-write' );
+ frame.setAttribute( 'allowfullscreen', '' );
+ frame.setAttribute( 'referrerpolicy', 'strict-origin-when-cross-origin' );
+ } else if ( result.sameorigin ) {
+ // Same-origin PDF that belongs to THIS package: served application/pdf + nosniff
+ // (never executable HTML), left unsandboxed so the browser's built-in PDF viewer
+ // renders it (it shows the broken-document icon inside a sandbox). The load guard
+ // still removes it if it redirects to the host origin.
+ frame.setAttribute( 'allow', 'fullscreen' );
+ frame.setAttribute( 'referrerpolicy', 'no-referrer' );
+ } else {
+ // Cross-origin PDF whose URL comes from the untrusted package. A server can serve
+ // scripted HTML at a ".pdf" path; UNSANDBOXED, that frame could top-navigate the
+ // host tab to a phishing page. Sandbox it WITHOUT allow-top-navigation/allow-scripts;
+ // allow-same-origin keeps the provider's own origin (SOP-isolated from the host).
+ // Trade-off: a genuine cross-origin PDF may show the broken-document icon. (audit M-3)
+ frame.setAttribute( 'sandbox', 'allow-same-origin' );
+ frame.setAttribute( 'allow', 'fullscreen' );
+ frame.setAttribute( 'referrerpolicy', 'no-referrer' );
+ }
+ frame.src = result.url;
+ // Tag with the URL it renders so sync() can detect when a reused embed id (the
+ // shim restarts its counter per page) now points at a different URL.
+ frame.setAttribute( 'data-exe-embed-src', result.url );
+ return frame;
+ }
+
+ /**
+ * Create a relay instance.
+ *
+ * @param {Object} config { mode: 'open'|'strict', whitelist: string[] }.
+ * @return {Object} The relay instance.
+ */
+ function createRelay( config ) {
+ config = config || {};
+ var strict = 'strict' === config.mode;
+ var whitelist = buildWhitelist( config.whitelist );
+ var overlays = [];
+ // Handles for the listeners/timer init() installs, so dispose() can remove
+ // them and init() can stay idempotent (a second init() must not stack a
+ // second drift interval + duplicate window listeners on the same relay).
+ var driftTimer = null;
+ var started = false;
+
+ function findOverlay( iframe ) {
+ for ( var i = 0; i < overlays.length; i++ ) {
+ if ( overlays[ i ].iframe === iframe ) {
+ return overlays[ i ];
+ }
+ }
+ return null;
+ }
+
+ // Resolve the CONTENT iframe a message came from. Promoted players are excluded
+ // (data-exe-embed-player): a sandboxed player with allow-same-origin could
+ // otherwise postMessage a forged 'sync' and impersonate a content source.
+ function frameForSource( source ) {
+ var frames = document.getElementsByTagName( 'iframe' );
+ for ( var i = 0; i < frames.length; i++ ) {
+ if ( frames[ i ].getAttribute( 'data-exe-embed-player' ) ) {
+ continue;
+ }
+ if ( frames[ i ].contentWindow === source ) {
+ return frames[ i ];
+ }
+ }
+ return null;
+ }
+
+ function overlayFor( iframe ) {
+ var entry = findOverlay( iframe );
+ if ( entry ) {
+ return entry;
+ }
+ var el = document.createElement( 'div' );
+ el.className = 'exe-embed-overlay';
+ el.style.cssText = 'position:absolute;overflow:hidden;pointer-events:none;z-index:2147483646;';
+ document.body.appendChild( el );
+ entry = { iframe: iframe, el: el, players: {} };
+ overlays.push( entry );
+ return entry;
+ }
+
+ function positionOverlay( entry, rect ) {
+ rect = rect || entry.iframe.getBoundingClientRect();
+ var scrollX = window.pageXOffset || document.documentElement.scrollLeft || 0;
+ var scrollY = window.pageYOffset || document.documentElement.scrollTop || 0;
+ entry.el.style.left = ( rect.left + scrollX ) + 'px';
+ entry.el.style.top = ( rect.top + scrollY ) + 'px';
+ entry.el.style.width = rect.width + 'px';
+ entry.el.style.height = rect.height + 'px';
+ // Remembered so checkDrift() can detect host-driven moves of the content
+ // iframe (panel toggles, sidebar show/hide) that fire no scroll/resize.
+ entry.lastRect = { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
+ }
+
+ /**
+ * Re-position any overlay whose content iframe box moved since it was last
+ * placed. The host page can move or resize the content iframe without any
+ * scroll/resize event firing (editor sidebar toggles, panel slide-ins, CSS
+ * transforms), which would strand the overlay at its old position. Called from a
+ * low-frequency interval in init(); one getBoundingClientRect per overlay per
+ * tick. Returns how many overlays were re-positioned.
+ *
+ * @return {number} The number of overlays re-positioned.
+ */
+ function checkDrift() {
+ var moved = 0;
+ for ( var i = 0; i < overlays.length; i++ ) {
+ var entry = overlays[ i ];
+ var rect = entry.iframe.getBoundingClientRect();
+ var last = entry.lastRect;
+ if (
+ ! last ||
+ rect.left !== last.left ||
+ rect.top !== last.top ||
+ rect.width !== last.width ||
+ rect.height !== last.height
+ ) {
+ positionOverlay( entry, rect );
+ moved += 1;
+ }
+ }
+ return moved;
+ }
+
+ // D1: if a promoted embed lands SAME-ORIGIN to the host (e.g. a cross-origin URL
+ // that 30x-redirects to this origin), with allow-same-origin it would become
+ // scriptable against this page -> remove it. A genuine cross-origin player throws
+ // on contentWindow.document (expected, kept). Not armed for same-origin package
+ // PDFs (intentionally same-origin, served as application/pdf).
+ function armSameOriginGuard( entry, id, player ) {
+ player.addEventListener( 'load', function () {
+ try {
+ if ( player.contentWindow && player.contentWindow.document ) {
+ if ( player.parentNode ) {
+ player.parentNode.removeChild( player );
+ }
+ if ( entry.players[ id ] === player ) {
+ delete entry.players[ id ];
+ }
+ }
+ } catch ( e ) { /* cross-origin: expected, keep the player */ }
+ } );
+ }
+
+ function sync( entry, embeds, contentSrc ) {
+ // The content iframe's box is invariant across this sync pass (the loop only
+ // mutates the overlay and its players), so read it once and reuse it for the
+ // overlay position and every player clamp -- avoids a forced reflow per embed.
+ var rect = entry.iframe.getBoundingClientRect();
+ positionOverlay( entry, rect );
+ var seen = {};
+ embeds.forEach( function ( embed ) {
+ if ( ! embed || typeof embed.id !== 'string' ) {
+ return;
+ }
+ if ( ! isFinite( embed.x ) || ! isFinite( embed.y ) || ! isFinite( embed.w ) || ! isFinite( embed.h ) ) {
+ return;
+ }
+ // id-only channel (DEC-0067): for recognised providers the shim reports
+ // {provider, objectId} and the parent rebuilds the canonical URL from a
+ // fixed template (the author URL never crosses for these). Unknown embeds
+ // keep the URL path. Either way validate() runs the structural invariant.
+ var raw = embed.url;
+ if ( embed.provider && embed.objectId ) {
+ raw = reconstructProvider( embed.provider, embed.objectId );
+ if ( ! raw ) {
+ return;
+ }
+ }
+ var result = validate( raw, contentSrc, { strict: strict, whitelist: whitelist } );
+ if ( ! result ) {
+ return;
+ }
+ seen[ embed.id ] = true;
+ var player = entry.players[ embed.id ];
+ // After the content navigates, the shim reuses ids (exe-embed-1, ...) for
+ // the new page's embeds. If this id now renders a different URL, drop the
+ // stale player so the previous page's video does not linger here.
+ if ( player && player.getAttribute( 'data-exe-embed-src' ) !== result.url ) {
+ player.parentNode.removeChild( player );
+ delete entry.players[ embed.id ];
+ player = null;
+ }
+ if ( ! player ) {
+ player = makePlayer( result );
+ entry.el.appendChild( player );
+ entry.players[ embed.id ] = player;
+ if ( ! result.sameorigin ) {
+ armSameOriginGuard( entry, embed.id, player );
+ }
+ }
+ // Defence in depth against clickjacking: the overlay is clamped to the
+ // content iframe's box and clips with overflow:hidden, so a player can
+ // never cover host UI outside the iframe. Cap the player size to the
+ // overlay too (the content reports geometry, the parent owns rendering).
+ // Reuses the iframe rect read once at the top of this pass.
+ player.style.left = embed.x + 'px';
+ player.style.top = embed.y + 'px';
+ player.style.width = Math.min( embed.w, rect.width ) + 'px';
+ player.style.height = Math.min( embed.h, rect.height ) + 'px';
+ } );
+ Object.keys( entry.players ).forEach( function ( id ) {
+ if ( ! seen[ id ] ) {
+ entry.players[ id ].parentNode.removeChild( entry.players[ id ] );
+ delete entry.players[ id ];
+ }
+ } );
+ }
+
+ function onMessage( event ) {
+ var data = event.data;
+ if ( ! data || 'exe-embed' !== data.type || 'sync' !== data.action || ! Array.isArray( data.embeds ) ) {
+ return;
+ }
+ var iframe = frameForSource( event.source );
+ if ( ! iframe ) {
+ return;
+ }
+ sync( overlayFor( iframe ), data.embeds, iframe.src );
+ }
+
+ // Ask every content iframe to report (covers the case where the shim fired its
+ // first report before this relay attached its listener). Promoted players are
+ // excluded so a sandboxed player is never pinged as a content source.
+ function pingAll() {
+ var frames = document.getElementsByTagName( 'iframe' );
+ for ( var i = 0; i < frames.length; i++ ) {
+ if ( frames[ i ].getAttribute( 'data-exe-embed-player' ) ) {
+ continue;
+ }
+ try {
+ frames[ i ].contentWindow.postMessage( { type: 'exe-embed', action: 'request' }, '*' );
+ } catch ( e ) {
+ // Cross-origin player iframes reject this; harmless.
+ }
+ }
+ }
+
+ var scheduled = false;
+ function scheduleReflow() {
+ if ( scheduled ) {
+ return;
+ }
+ scheduled = true;
+ window.requestAnimationFrame( function () {
+ scheduled = false;
+ for ( var i = 0; i < overlays.length; i++ ) {
+ positionOverlay( overlays[ i ] );
+ }
+ } );
+ }
+
+ // Remove every overlay and its players (teardown of the rendered layer).
+ function clear() {
+ for ( var i = 0; i < overlays.length; i++ ) {
+ var entry = overlays[ i ];
+ var ids = Object.keys( entry.players );
+ for ( var j = 0; j < ids.length; j++ ) {
+ var player = entry.players[ ids[ j ] ];
+ if ( player && player.parentNode ) {
+ player.parentNode.removeChild( player );
+ }
+ }
+ entry.players = {};
+ if ( entry.el && entry.el.parentNode ) {
+ entry.el.parentNode.removeChild( entry.el );
+ }
+ }
+ overlays.length = 0;
+ }
+
+ // Tear down the overlays AND remove the window listeners + drift timer that
+ // init() installed, so a relay whose host is gone (preview panel disposed,
+ // tab closed) leaves nothing running. Idempotent; init() can run again
+ // afterwards on a reused relay.
+ function dispose() {
+ clear();
+ /* v8 ignore start */
+ if ( null !== driftTimer ) {
+ window.clearInterval( driftTimer );
+ driftTimer = null;
+ }
+ window.removeEventListener( 'message', onMessage );
+ window.removeEventListener( 'resize', scheduleReflow );
+ window.removeEventListener( 'scroll', scheduleReflow, true );
+ window.removeEventListener( 'load', pingAll );
+ /* v8 ignore stop */
+ started = false;
+ }
+
+ return {
+ onMessage: onMessage,
+ sync: sync,
+ checkDrift: checkDrift,
+ dispose: dispose,
+ validate: function ( raw, contentSrc ) {
+ return validate( raw, contentSrc, { strict: strict, whitelist: whitelist } );
+ },
+ init: function () {
+ // Idempotent: a second init() on the same relay must not stack a
+ // second drift interval and duplicate window listeners.
+ if ( started ) {
+ return this;
+ }
+ started = true;
+ window.addEventListener( 'message', onMessage );
+ window.addEventListener( 'resize', scheduleReflow );
+ window.addEventListener( 'scroll', scheduleReflow, true );
+ window.addEventListener( 'load', pingAll );
+ pingAll();
+ window.setTimeout( pingAll, 500 );
+ // Host layout changes (sidebar toggles, panel slide-ins) move the
+ // content iframe with no scroll/resize event; keep the overlays pinned
+ // to it with a cheap low-frequency drift check.
+ driftTimer = window.setInterval( checkDrift, 300 );
+ return this;
+ }
+ };
+ }
+
+ var exp = {
+ buildWhitelist: buildWhitelist,
+ contentDir: contentDir,
+ packageId: packageId,
+ isSameOriginPackageFile: isSameOriginPackageFile,
+ isIpOrLocalHost: isIpOrLocalHost,
+ normalizeHost: normalizeHost,
+ isRelatedToLms: isRelatedToLms,
+ isCrossOriginHttps: isCrossOriginHttps,
+ reconstructProvider: reconstructProvider,
+ validate: validate,
+ makePlayer: makePlayer,
+ createRelay: createRelay
+ };
+ // Test runner (Vitest/Node) consumes module.exports.
+ if ( typeof module !== 'undefined' && module.exports ) {
+ module.exports = exp;
+ }
+ // Browser bootstrap: expose the factory and helpers, then auto-run a relay from the
+ // host-injected config (window.ExeEmbedRelayConfig is set before this script loads).
+ if ( typeof window !== 'undefined' ) {
+ window.exeEmbedRelay = exp;
+ createRelay( window.ExeEmbedRelayConfig || {} ).init();
+ }
+} )();
diff --git a/assets/js/exe-embed-shim.js b/assets/js/exe-embed-shim.js
new file mode 100644
index 0000000..fa03239
--- /dev/null
+++ b/assets/js/exe-embed-shim.js
@@ -0,0 +1,280 @@
+/**
+ * eXeLearning external-embed shim (runs INSIDE the opaque-origin content iframe).
+ *
+ * In secure mode the .elpx HTML runs in a sandboxed, opaque-origin iframe. The
+ * sandbox origin flag propagates to any nested iframe, so cross-origin players
+ * (YouTube, Vimeo, ...) lose their own origin and render blank. This shim, injected
+ * by the content proxy only in secure mode, replaces each cross-origin (https) or
+ * .pdf '
+ );
+ }
- // Add "Edit in eXeLearning" button if user can edit
- addEditButton( attachment, $insertPoint );
+ // "Edit in eXeLearning" centered below the preview (like the native
+ // "Edit image" button).
+ addExeEditAction( attachment, $detailsThumbnail );
+ }
- // Add "Preview in new tab" link after the edit button
- var $previewLink = $(
- '
'
- );
- // Find the edit button in the parent container and insert after it
- var $editBtn = $detailsThumbnail.parent().find( '.exelearning-edit-button' );
- if ( $editBtn.length > 0 ) {
- $editBtn.after( $previewLink );
- $insertPoint = $previewLink;
- } else {
- $insertPoint.after( $previewLink );
- $insertPoint = $previewLink;
+ // Add the centered "Edit in eXeLearning" action below $anchor, mirroring the
+ // native "Edit image" button. The exelearning-edit-page-button class makes
+ // exelearning-editor.js open the in-page editor modal (href is the fallback).
+ function addExeEditAction( attachment, $anchor ) {
+ if ( ! attachment.get( 'exelearningCanEdit' ) ) {
+ return;
}
-
- // Add metadata at the end (after buttons)
- if ( metaHtml ) {
- var $meta = $( metaHtml );
- $insertPoint.after( $meta );
+ var editUrl = attachment.get( 'exelearningEditUrl' );
+ var id = esc( attachment.get( 'id' ) );
+ var label = ( strings.editInExe || 'Edit in eXeLearning' );
+
+ if ( $anchor.closest( '.media-sidebar' ).length > 0 ) {
+ // Media selection sidebar: a plain blue link, like the native
+ // "Edit image" link — editing is not the primary task here.
+ $anchor.after(
+ '
'
);
- $editButton.on( 'click', function( e ) {
+ $row.find( '.exelearning-edit-button-actions' ).on( 'click', function( e ) {
e.preventDefault();
-
- // Use the ExeLearningEditor modal if available
if ( window.ExeLearningEditor && typeof window.ExeLearningEditor.open === 'function' ) {
window.ExeLearningEditor.open( attachmentId, editUrl );
} else {
- // Fallback: open in new window
window.open( editUrl, '_blank', 'width=1200,height=800' );
}
});
// Insert at the beginning of the actions div
- $actions.prepend( $editButton );
+ $actions.prepend( $row );
}
// Run all update functions
function runAllUpdates() {
replaceElpThumbnail();
+ // addElpPreviewToDetails() renders the large preview + a single centered
+ // "Edit in eXeLearning" action below it (addExeEditAction), mirroring the
+ // native "Edit image" button — in both the details modal and the selection
+ // sidebar. No separate button in the actions area, no fullscreen here.
addElpPreviewToDetails();
- addEditButtonToAttachmentInfo();
}
// Observe DOM changes to detect when attachments are added
diff --git a/blueprint.json b/blueprint.json
index b1539a7..9a54142 100644
--- a/blueprint.json
+++ b/blueprint.json
@@ -38,6 +38,11 @@
"step": "runPHP",
"code": "set_permalink_structure('/%postname%/'); $wp_rewrite->flush_rules(true); ?>"
},
+ {
+ "step": "writeFile",
+ "path": "/wordpress/wp-content/mu-plugins/exelearning-playground-legacy.php",
+ "data": "
eXeLearning: the UNSAFE legacy same-origin iframe (EXELEARNING_UNSAFE_LEGACY_IFRAME) is enabled by this Playground-only mu-plugin. Embedded content runs same-origin because php-wasm cannot serve opaque subframes. This dev-only escape hatch must never ship in production.
\";\n} );\n"
+ },
{
"step": "writeFile",
"path": "/tmp/test-content.elpx",
diff --git a/docs/experiments/secure-opfs-sandbox-analysis.md b/docs/experiments/secure-opfs-sandbox-analysis.md
new file mode 100644
index 0000000..181417c
--- /dev/null
+++ b/docs/experiments/secure-opfs-sandbox-analysis.md
@@ -0,0 +1,225 @@
+# Secure OPFS sandbox experiment: WordPress baseline analysis
+
+## Scope and evidence
+
+This document records the pre-implementation baseline for the OPFS + Service Worker experiment. It does not claim that OPFS is a security sandbox and does not activate or remove any transport.
+
+| Item | Value |
+| --- | --- |
+| Experimental branch | `feature/secure-opfs-sandbox` |
+| Local `main` SHA | `faebab692e522d111f9c2db36654bb5109029430` |
+| Experimental branch initial SHA | `faebab692e522d111f9c2db36654bb5109029430` |
+| Reference PR | exelearning/wp-exelearning#56 |
+| Reference branch inspected | `origin/feature/secure-iframe-sandbox` |
+| Reference SHA | `3697029120bee28cd64ca63b2f2b9be0b5f877ca` |
+| Stale local reference branch | `feature/secure-iframe-sandbox` at `30800a8479e45b3238d40fb60dfc0a204cf0d6e7` |
+
+The reference was analyzed with `git log main..reference`, `git diff --stat main...reference`, `git diff --name-status main...reference`, and the complete diff. The remote-tracking branch is used because it contains the PR tip; the similarly named local branch is older.
+
+## Invariant and experiment priority
+
+OPFS determines where bytes are stored. A Service Worker converts those bytes into virtual HTTP responses. The iframe origin and sandbox determine what authored JavaScript can attack. These properties are independent.
+
+The primary target is the static/PWA editor preview, whose generated documents and project asset bytes are already in the browser. The plugin experiment is a secondary adapter. If an OPFS viewer cannot satisfy the security or compatibility gates in WordPress, the opaque HTTP capability transport remains the supported fallback. Because no dedicated viewer origin is available for the current deployment, a same-origin OPFS result can only be classified as `same-origin-trusted`; it cannot replace the opaque boundary for untrusted authored content.
+
+## Current and reference designs
+
+### `main`
+
+WordPress embeds a static editor and currently carries path adaptations for its Service Worker viewer. Published ELPX content is extracted and served by the plugin's existing content proxy. Editor preview, published rendering, authenticated save/load, and media attachment storage are distinct concerns and must remain distinct.
+
+### Reference opaque HTTP preview
+
+PR #56 introduces Preview Serving Contract v2. The editor receives a `previewHttp` configuration with separate management and serving base URLs. Authenticated, nonce-protected management routes create a session, upload immutable assets, atomically publish document revisions, and delete the session. A cookieless capability route serves the active tree. Authored documents run in an iframe without `allow-same-origin` and scriptable responses receive a sandbox CSP.
+
+The private file-backed store defaults outside `wp-content/uploads`, is site-scoped, enforces owner/session/global budgets, retains revisions for in-flight reads, and is reclaimed through request-time expiry and WP-Cron. Fixed build resources are resolved rather than uploaded. The serving controller implements explicit MIME types, `nosniff`, ETag, single-range responses, relative bare-root redirects, and traversal-safe exact-key lookup.
+
+The adapter intentionally requires pretty permalinks because the capability subtree must preserve relative resource navigation. Its released editor artifact predates the HTTP provider, so endpoint tests alone do not prove end-to-end activation.
+
+## Relevant files in the reference branch
+
+### Editor integration and configuration
+
+- `admin/views/editor-bootstrap.php`
+- `includes/class-exelearning-editor.php`
+- `includes/class-exelearning.php`
+- `exelearning.php`
+- `blueprint.json`
+
+### HTTP preview management, storage, and serving
+
+- `includes/class-preview-management-controller.php`
+- `includes/class-preview-serving-controller.php`
+- `includes/class-preview-session-store.php`
+- `includes/class-preview-proxy.php`
+- `includes/class-preview-fixed-resources.php`
+- `includes/class-preview-http-headers.php`
+- `includes/class-activator.php`
+- `includes/class-deactivator.php`
+- `nginx-exelearning-preview.conf`
+- `docs/preview-serving-contract.md`
+
+### Published-content isolation and bridges
+
+- `includes/class-content-proxy.php`
+- `includes/class-iframe-sandbox.php`
+- `public/class-shortcodes.php`
+- `assets/js/exe-embed-relay.js`
+- `assets/js/exe-embed-shim.js`
+- `assets/js/exe-media-host.js`
+- `assets/js/exe-media-policy.js`
+
+### Evidence and conformance
+
+- `tests/fixtures/preview-contract/vectors.json`
+- `tests/unit/PreviewContractVectorsTest.php`
+- `tests/unit/PreviewManagementControllerTest.php`
+- `tests/unit/PreviewServingControllerTest.php`
+- `tests/unit/PreviewSessionStoreTest.php`
+- `tests/unit/PreviewFixedResourcesTest.php`
+- `tests/unit/PreviewHttpHeadersTest.php`
+- `tests/e2e/editor-preview-http.spec.js`
+- `tests/e2e/embed.spec.cjs`
+- `tests/js/exe_embed.test.js`
+- `tests/js/exe_media_host.test.js`
+
+## Classification of reference changes
+
+| Category | WordPress reference implementation | OPFS experiment disposition |
+| --- | --- | --- |
+| 1. HTTP editor preview | `previewHttp` bootstrap and REST adapter | Candidate to bypass only for editor preview; keep as fallback |
+| 2. Temporary preview storage | Private site-scoped file store | Candidate to replace with browser-local OPFS for preview only |
+| 3. Capability serving URL | Cookieless `/preview/{previewId}/{path}` subtree | Not needed for a functional same-origin OPFS preview; still required by opaque HTTP fallback |
+| 4. Authenticated session management | REST nonce, capability check, ownership | Preview-only routes may disappear; parent authentication and save/load remain |
+| 5. Published-content CSP and headers | Content proxy and iframe response hardening | Must remain; OPFS preview does not replace publication policy |
+| 6. Opaque published-content iframe | `IframeSandbox`, omitted `allow-same-origin`, sandbox CSP | Must remain for untrusted content unless equivalent evidence exists |
+| 7. SCORM/xAPI bridge | Not the main focus of this WordPress PR | Preserve host-side credentials/tracking where present |
+| 8. External-media bridge | Relay, shim, media host, provider policy | Reuse; do not remove without browser evidence |
+| 9. Interactive-video bridge | Media relay lifecycle and geometry behavior | Reuse and test independently |
+| 10. PDF and downloads | Media host, sandboxed players, Range serving | Reuse policy/tests; OPFS SW must independently prove Range/PDF behavior |
+| 11. Fixed resources | Manifest-backed fixed-resource resolver | Reuse the layered model and canonical identifiers |
+| 12. Incremental revisions | Immutable assets plus atomic document deltas | Reuse protocol semantics and conformance vectors |
+| 13. Security tests | Nonce, ownership, CSP, traversal, iframe and media tests | Reuse as baseline; add OPFS/storage and management-channel attacks |
+| 14. Integration tests | HTTP preview and embed Playwright flows | Keep HTTP baseline and add static/PWA-first OPFS A/B tests |
+| 15. Shared reusable code | Vectors, MIME/CSP rules, path/range semantics, bridges | Prefer canonical core implementations and vendored conformance data |
+| 16. Code potentially removable with OPFS | Preview REST routes, store, WP-Cron cleanup, private-store nginx rule, preview quotas, permalink dependency | Remove only in a separate, measured commit after gates pass; fallback requirement currently prevents removal |
+| 17. Code still required with OPFS | Editor bootstrap, authentication, attachment access, save/load, publication proxy, iframe policy, bridges, tests | Preserve and keep security boundaries explicit |
+
+## Reusable components
+
+- The three-layer input model: generated documents, immutable project assets, and build-fixed resources.
+- Exact path normalization, MIME classification, ETag, query-string independence, and single-range semantics.
+- Atomic revision behavior: stage, verify, activate a small pointer, retain the previous revision.
+- Canonical contract vectors and negative cases rather than framework-specific protocol invention.
+- External media/PDF relay lifecycle, exact origin checks, and id-only authored messages.
+- The split between credentialed management and credential-free serving. Even if management becomes a `MessageChannel`, this is a useful privilege-separation model.
+- Tests proving owner separation, failed-write behavior, CSP byte identity, traversal rejection, and cleanup.
+
+## Preview-only candidates to disappear
+
+If and only if the browser-local experiment passes, the editor preview could avoid `class-preview-management-controller.php`, `class-preview-serving-controller.php`, `class-preview-session-store.php`, `class-preview-proxy.php`, preview activation/deactivation hooks, preview-specific WP-Cron cleanup, backend preview quotas, the private-store nginx rule, multipart upload handling, and the pretty-permalink requirement. It would also send zero generated documents and zero project asset bytes to a preview backend.
+
+These are not candidates for immediate deletion because the user-selected deployment has no dedicated viewer origin and explicitly retains HTTP as the plugin fallback.
+
+## Components that remain necessary
+
+- WordPress authentication, REST nonces, capability checks, and attachment authorization outside preview management.
+- Existing save/load and ELPX upload/download flows.
+- Published-content proxying, CSP, iframe sandboxing, access control, and URL handling.
+- SCORM/xAPI or equivalent host-side tracking validation when applicable.
+- External-media, interactive-video, fullscreen, resize, and PDF behavior until replacement tests pass.
+- The trusted viewer shell and exact parent-origin validation for any OPFS experiment.
+- Recovery UX when OPFS is unavailable, evicted, private-mode-limited, or quota-exhausted.
+
+## Risks of substitution
+
+1. A same-origin Service Worker response has a normal WordPress origin unless an effective response sandbox makes it opaque. `OPFS + SW` alone would expose the WordPress origin and is a security regression.
+2. A CSP `sandbox` response may make the authored document opaque, but it may also prevent the document from acting as a controlled Service Worker client or loading viewer subresources. This must be tested in Chromium, Firefox, and WebKit; it cannot be inferred.
+3. Authored code with the viewer origin may access that origin's OPFS, IndexedDB, CacheStorage, and Service Worker registrations. Random directory names are not isolation when enumeration is possible.
+4. A WordPress-scoped worker must not control general administration, REST, media, or public pages. Scope and `Service-Worker-Allowed` behavior are hard gates.
+5. Private browsing, storage partitioning, quota eviction, and third-party iframe policy may disable or reduce OPFS/SW persistence.
+6. PDF native viewers and large media can behave differently for Service Worker responses; Range, reload, seek, cancellation, and files over 100 MiB require browser tests.
+7. Removing the HTTP fallback would trade multi-replica backend complexity for browser protocol, persistence, lifecycle, and compatibility complexity without necessarily reducing total complexity.
+
+## Cross-repository dependencies
+
+- eXeLearning core is the canonical source for provider selection, OPFS protocol version, storage layout, viewer shell, Service Worker, CSP policy, and conformance vectors.
+- WordPress must consume one reproducible static-editor artifact and record its core commit and SHA-256; it must not fork the protocol.
+- Moodle, Omeka S, Nextcloud, and Procomun must use the same vectors so host deviations are explicit.
+- The security paper must distinguish byte materialization from JavaScript execution origin and report same-origin OPFS as compatibility mode, not opaque isolation.
+- Published-content integration remains host-specific because initial OPFS is empty and authorized package delivery still crosses the network.
+
+## Initial impact estimate
+
+The reference PR changes 75 files, with 16,767 insertions and 6,248 deletions including generated translations and lockfile churn. Excluding `languages/` and `package-lock.json`, it changes 52 files with approximately 9,136 insertions and 124 deletions. It adds six preview backend classes, at least seven preview-focused PHPUnit files, and a dedicated editor-preview E2E flow.
+
+| Area | Opaque HTTP reference | OPFS experiment target | Expected difference |
+| --- | ---: | ---: | ---: |
+| Preview backend routes | 5 operations | 0 for browser-local preview | -5 |
+| Preview backend classes | 6 | 0 preview-session classes | -6 |
+| Backend preview store | 1 file-backed store | 0 | -1 |
+| Cleanup mechanisms | WP-Cron plus request expiry | Browser TTL/LRU/recovery | Backend -2; browser +1 subsystem |
+| Browser transports | HTTP provider in core | OPFS/SW provider plus HTTP fallback | +1 while A/B is retained |
+| Protocol operations | Create/assets/revision/delete/serve | MessageChannel session/batches/commit/status/clear | More browser states, fewer HTTP operations |
+| Operational requirements | Private disk, TTL, quotas, cron, pretty permalinks | SW/OPFS support, scope, quota, persistence | Shift, not yet proven reduction |
+| Security boundary | Opaque sandbox + capability response | Same-origin normal origin unless sandbox response works | Potential regression; hard gate |
+
+## Hypotheses to prove
+
+1. A static/PWA editor can generate documents, write them and project assets to OPFS, and navigate a multipage tree through normal Service Worker URLs without backend preview uploads.
+2. The tree survives worker termination because all authoritative state is persisted, not held in a global `Map`.
+3. Atomic pointer activation yields revision N or N+1, never mixed files.
+4. Unchanged immutable assets are neither rewritten nor retransferred.
+5. `Content-Security-Policy: sandbox allow-scripts` either preserves controlled navigation/subresource loading in every target engine or provides reproducible evidence that it does not.
+6. Authored content cannot access the WordPress parent DOM, cookies, storage, request nonce, or management channel.
+7. The worker's scope cannot cover unrelated WordPress paths and authored documents cannot replace or unregister the trusted worker.
+8. PDF, audio, video, attachments, ESM, XML, SVG, fonts, and single-range reads behave correctly.
+9. Failure modes (unsupported API, eviction, private mode, quota, interrupted batches) recover explicitly to the opaque HTTP transport rather than silently weakening security.
+10. Total implementation and operational complexity is lower after counting the trusted shell, storage abstraction, protocol states, browser tests, and retained fallback.
+
+## Baseline measurements and required experiment outputs
+
+The figures above are source-diff measurements, not performance benchmark results. Core has now supplied functional and security evidence for the transport profiles; WordPress-specific runtime and performance cells remain `NOT EXECUTED`. A future compatible WordPress experiment would record JSON, CSV, and Markdown results for five cold and ten warm runs where practical: first render, text update, image replacement, page navigation, worker restart recovery, bytes uploaded to preview endpoints, OPFS bytes written, request count, memory estimate, cleanup time, and package mount cost for published content.
+
+Network interception must fail the editor-preview test if generated HTML/CSS/JavaScript or project images, PDF, audio, video, or attachments are uploaded to WordPress preview routes. Reading an asset that the editor does not yet possess is measured separately from re-uploading it to a preview server.
+
+## Empirical core results applied to WordPress
+
+The canonical core experiment produced the following results. `PASS` means the functional behavior under test worked; it does not imply that the profile passed the separate host-isolation requirement.
+
+| Profile / engine | Multipage OPFS/SW | Host isolation | Result for WordPress |
+| --- | --- | --- | --- |
+| Same-origin trusted / Chromium | `PASS` | `FAIL` | Functional only; unsafe for untrusted authored content on the WordPress origin |
+| Same-origin trusted / Firefox | `PASS` | `FAIL` | Functional only; unsafe for untrusted authored content on the WordPress origin |
+| Opaque CSP / Chromium and Firefox | `FAIL` | Opaque boundary requested | Multipage OPFS-backed navigation/subresources are incompatible with the required opaque profile |
+| WebKit | `NOT SUPPORTED` | `NOT SUPPORTED` | Cannot satisfy the cross-engine acceptance criterion |
+| Dedicated viewer origin | `NOT EXECUTED` | `NOT EXECUTED` | Not available in the target deployment and therefore not a deployable mitigation |
+
+The experiment confirms the distinction in this document's invariant: same-origin OPFS successfully stores and serves bytes, but it does not isolate authored JavaScript from WordPress. Applying the opaque CSP needed to restore that boundary breaks the multipage OPFS design. The only potentially viable normal-origin security profile, a dedicated sacrificial origin, is unavailable and untested here.
+
+## Host decision and retained code
+
+| Question | Decision | Code or boundary retained |
+| --- | --- | --- |
+| Add `opfsViewer` configuration to the WordPress bootstrap | No | Keep `previewHttp` configuration and its fail-closed checks |
+| Add OPFS management or serving endpoints | No | Do not add insecure same-origin surfaces; retain the existing HTTP management and capability routes from the reference design |
+| Replace private preview storage | No | Retain the private site-scoped preview session store, quotas, atomic revisions, expiry, and WP-Cron cleanup |
+| Remove capability URLs or pretty-permalink validation | No | Retain cookieless capability serving and the permalink requirement while HTTP preview is used |
+| Relax the iframe sandbox with `allow-same-origin` | No | Retain the opaque iframe and sandbox-first CSP for authored content |
+| Remove publication proxy or headers | No | Retain `class-content-proxy.php`, publication access control, MIME hardening, and CSP |
+| Remove media, PDF, or interactive bridges | No | Retain relay, shim, media host, media policy, Range behavior, fullscreen, geometry, and lifecycle tests |
+| Use OPFS in the static/PWA editor | Yes, conditionally | Core may use it only as `same-origin-trusted`, outside the plugin's untrusted host boundary |
+| Use OPFS for published WordPress content | No | Retain the existing authenticated/publication delivery path and opaque rendering baseline |
+
+The candidate-removal list above is therefore counterfactual for WordPress. No preview endpoint, controller, private-store component, cleanup mechanism, security header, sandbox rule, or bridge should be removed on the evidence currently available.
+
+## Baseline decision
+
+The plugin decision is **NO-GO** for replacing the opaque HTTP preview with OPFS. This repository will not implement OPFS endpoints or inject same-origin `opfsViewer` configuration because that would knowingly select the profile whose host-isolation test failed.
+
+- static/PWA preview: conditionally viable in trusted-content mode on Chromium and Firefox;
+- WordPress untrusted preview: retain the opaque HTTP capability transport;
+- same-origin OPFS inside WordPress: rejected for untrusted content because host isolation failed;
+- opaque CSP OPFS: rejected because multipage serving failed;
+- dedicated-origin OPFS: unavailable and `NOT EXECUTED`;
+- published content: retain the current path; no OPFS substitution.
diff --git a/docs/preview-serving-contract.md b/docs/preview-serving-contract.md
new file mode 100644
index 0000000..0d6e595
--- /dev/null
+++ b/docs/preview-serving-contract.md
@@ -0,0 +1,93 @@
+# Opaque editor preview — WordPress adapter
+
+The embedded editor renders its preview **filtered** by default: sanitised, with
+no author JavaScript running. When the author opts in to running their own code,
+the editor needs somewhere to put the real project bytes that is **not** the
+WordPress page — a browser-enforced **opaque origin** the content cannot reach
+out of.
+
+This plugin is that somewhere. The editor POSTs the whole project as one ZIP and
+gets back an unguessable capability id; the plugin serves that tree from an
+authless REST route under a sandbox CSP. There is no `srcdoc` transport and no
+Service Worker fallback for authored content: missing or invalid configuration
+**fails closed** and the filtered preview stays.
+
+## The two routes
+
+| | Request | Result |
+|---|---|---|
+| Management | `POST {REST}/exelearning/v1/preview-session/{attachmentId}` | multipart `snapshot=`, optional `previewId` → `{previewId}` |
+| Management | `DELETE {REST}/exelearning/v1/preview-session/{attachmentId}/{previewId}` | drops the snapshot |
+| Serving | `GET {REST}/exelearning/v1/preview/{previewId}/{file}` | the snapshot, authless |
+
+Management runs on the WordPress cookie/REST path: `X-WP-Nonce`, the
+`upload_files` capability, and ownership of the snapshot (user + attachment).
+Serving is authless and cookieless — the unguessable id plus the idle TTL is the
+whole credential, which is what makes the origin opaque.
+
+**Pretty permalinks are required in practice.** The editor builds the preview URL
+by resolving `{previewId}/index.html` against `servingBaseUrl`, and under plain
+`?rest_route=` permalinks that resolution drops the query string and lands on the
+site root. `ExeLearning_Editor::maybe_warn_preview_permalinks()` raises an
+administration notice; the configuration itself is still emitted.
+
+## Why one whole snapshot
+
+An earlier revision implemented a layered protocol (contract v2): immutable asset
+keys uploaded once, incremental document revisions and a fixed-resource manifest,
+all to avoid re-uploading unchanged bytes. The editor stopped speaking it — it
+sends one whole snapshot per refresh — so the layered store, its controllers and
+the `previewHttp` bootstrap block were removed. This document describes what the
+plugin does now.
+
+## Storage
+
+Snapshots live outside the web root, under the system temp directory, so no
+direct web-server path can bypass the serving route and its sandbox CSP.
+
+Content sits in its own subtree; a write is staged and swapped in, so a reader
+sees the previous snapshot or the new one, never a half-written one.
+
+## What an archive must survive before extraction
+
+`ExeLearning_Preview_Zip_Inspector` vets every entry *before* a byte is written,
+because extraction is all or nothing and a limit noticed halfway would leave a
+partial tree:
+
+- entry count and total **declared uncompressed** size — a zip bomb inflates past
+ the second, not the first;
+- path traversal, absolute paths and backslashes;
+- reserved names the store owns;
+- Unix symlinks, stored as a tiny entry whose contents are a path.
+
+Limits default to 1 GB / 10 000 entries, overridable with the
+`EXELEARNING_PREVIEW_MAX_BYTES` / `EXELEARNING_PREVIEW_MAX_FILES` constants or the
+matching filters. A non-positive value falls back to the default: the guard
+cannot be switched off. Over the limit the grant **fails closed** — the filtered
+preview stays and the author sees an error, rather than the isolation boundary
+being downgraded.
+
+## Response security policy
+
+Every response carries `X-Content-Type-Options: nosniff`, `Referrer-Policy:
+no-referrer` and a restrictive `Permissions-Policy`. Every **scriptable** type —
+`text/html`, `image/svg+xml`, XML, XHTML — additionally carries the sandbox-first
+CSP, so a capability URL stays opaque even when opened directly. Not just HTML: an
+author-supplied SVG runs its inline `';
+
+ if ( false !== stripos( $html, '' ) ) {
+ return preg_replace( '/<\/body>/i', $script . '', $html, 1 );
+ }
+ return $html . $script;
+ }
+
+ /**
+ * Whether the current request is a genuine top-level navigation.
+ *
+ * Embedded requests report Sec-Fetch-Dest: iframe; a real address-bar / new-tab
+ * navigation reports 'document'. When the header is absent (older browsers) we
+ * treat it as embedded and serve the content, since the response is sandboxed.
+ *
+ * @return bool True for a top-level document navigation.
+ */
+ private function is_toplevel_navigation() {
+ $dest = isset( $_SERVER['HTTP_SEC_FETCH_DEST'] )
+ ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_SEC_FETCH_DEST'] ) )
+ : '';
+ return 'document' === $dest;
+ }
+
+ /**
+ * Build the "open it embedded" notice served on a top-level navigation.
+ *
+ * The package content is untrusted and only meant to run inside the embedding
+ * iframe (where the host page's relay renders external players and the parent
+ * origin isolates it). Opened top-level there is no host, so instead of running
+ * the content we return this short, script-free notice.
+ *
+ * @return string Notice HTML document.
+ */
+ private function build_toplevel_notice() {
+ $title = esc_html__( 'This content lives inside a page', 'exelearning' );
+ $message = esc_html__( 'This eXeLearning activity is meant to be viewed embedded in a WordPress page (through a shortcode or block), not on its own. Open the page that includes it to enjoy the interactive content.', 'exelearning' );
+ $lang = esc_attr( get_bloginfo( 'language' ) );
+ // eXeLearning icon (assets/images/exelearning-icon.svg), inlined so it renders
+ // under the opaque-origin sandbox without a cross-origin image request.
+ $icon = '';
+ return ''
+ . '' . $title . ''
+ . ''
+ . '
'
+ . $icon
+ . '
' . $title . '
'
+ . '
' . $message . '
';
+ }
+
+ /**
+ * Serve the top-level "open it embedded" notice with the standard headers.
+ *
+ * @param string $mime_type Response MIME type (text/html).
+ */
+ private function serve_toplevel_notice( $mime_type ) {
+ $html = $this->build_toplevel_notice();
+ $this->send_headers( $mime_type, strlen( $html ) );
+ // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Static markup, values escaped in build_toplevel_notice().
+ echo $html;
+ }
+
+ /**
+ * Read and cache the embed shim JavaScript source.
+ *
+ * @return string Shim source, or '' if the asset is unreadable.
+ */
+ private static function embed_shim_source() {
+ static $cache = null;
+ if ( null !== $cache ) {
+ return $cache;
+ }
+ $path = EXELEARNING_PLUGIN_DIR . 'assets/js/exe-embed-shim.js';
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading bundled plugin asset.
+ $source = is_readable( $path ) ? file_get_contents( $path ) : false;
+ $cache = ( false === $source ) ? '' : $source;
+ return $cache;
+ }
+
/**
* Serve CSS content with url() references rewritten to absolute proxy URLs.
* This is needed when pretty permalinks are disabled.
@@ -599,33 +718,14 @@ private function send_headers( $mime_type, $file_size ) {
header( 'X-Frame-Options: SAMEORIGIN' );
}
header( 'X-Content-Type-Options: nosniff' );
- header( 'Referrer-Policy: same-origin' );
+ header( 'Referrer-Policy: no-referrer' );
header( 'Permissions-Policy: geolocation=(), microphone=(), camera=(), payment=()' );
- // CSP. HTML is the eXeLearning package's own (interactive) document, so it
- // keeps a functional policy. SVG/XML are served as images/data and must
- // NEVER execute script: a malicious uploaded .elpx could otherwise carry
- // an