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 ab2264c..d4e8c6b 100644
--- a/.phpcs.xml.dist
+++ b/.phpcs.xml.dist
@@ -9,7 +9,10 @@
/tests/*
/includes/vendor/*
/dist/*
- /exelearning/*
+
+ */wp-content/uploads/exelearning/*
diff --git a/README.md b/README.md
index 93f2b09..190edd1 100644
--- a/README.md
+++ b/README.md
@@ -55,6 +55,18 @@ EXELEARNING_EDITOR_REF=my-feature EXELEARNING_EDITOR_REF_TYPE=branch make build-
> **Important:** It is recommended to download from [Releases](https://github.com/exelearning/wp-exelearning/releases) for production use, which includes the embedded editor pre-built. If you clone the repository without building the editor, you can install it from the WordPress admin panel at **Settings > eXeLearning** using the "Download & Install Editor" button, which fetches the latest static editor package from GitHub Releases automatically. No remote loading or proxy is used at runtime.
+### 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 29f2428..0d195cf 100644
--- a/admin/views/editor-bootstrap.php
+++ b/admin/views/editor-bootstrap.php
@@ -111,6 +111,15 @@
'fallbackTheme' => 'base',
);
+// Opaque HTTP preview transport (serving contract v2). Emitted only under
+// pretty permalinks; otherwise `previewHttp` is omitted so the editor fails
+// closed (no silent same-origin fallback) and ExeLearning_Editor's admin notice
+// explains the requirement. wp_json_encode() output is valid JS object syntax.
+$exelearning_preview_http = ExeLearning_Editor::build_preview_http_config( $exelearning_nonce );
+$exelearning_preview_http_js = null === $exelearning_preview_http
+ ? ''
+ : "\n previewHttp: " . wp_json_encode( $exelearning_preview_http ) . ',';
+
// 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 +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 over HTTP (see previewHttp 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 +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]);
@@ -243,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;
@@ -482,6 +414,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_http_js,
esc_url( $exelearning_plugin_assets_url )
);
// phpcs:enable WordPress.WP.EnqueuedResources.NonEnqueuedScript
@@ -538,10 +471,20 @@ 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 guard so the bundled pre-HTTP-v2 editor can never register a
+// same-origin /viewer/ preview worker on the WordPress origin (interim
+// protection; see ExeLearning_Editor::service_worker_guard_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_sw_guard_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_sw_guard_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/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