Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
81 commits
Select commit Hold shift + click to select a range
cf389a2
feat(security): add secure (opaque-origin) content iframe mode
erseco Jun 13, 2026
9eb07ff
i18n: translate the secure-iframe admin strings; trim the settings copy
erseco Jun 13, 2026
1452468
style(admin): inline the mode write to satisfy phpcs alignment sniff
erseco Jun 13, 2026
7adca0c
fix(security): sandbox the content response in secure mode (raw-route…
erseco Jun 13, 2026
4416ccd
Render external embeds (YouTube/Vimeo/PDF) inline in secure mode
erseco Jun 14, 2026
4266410
Report absolute embed URLs from the shim (resolve relative srcs)
erseco Jun 14, 2026
76f40fe
Add Firefox e2e for secure-mode external embeds
erseco Jun 14, 2026
15f1b71
Keep the embed Firefox e2e out of the WordPress (chromium/wp-env) Pla…
erseco Jun 14, 2026
8fc1354
Mirror canonical embed updates: providers, clamp, allow-forms, page-n…
erseco Jun 14, 2026
53fd157
Fix PHPCS: capitalize the allow-forms docblock long description
erseco Jun 14, 2026
f162e72
Mirror canonical embeds: structural invariant (any cross-origin https…
erseco Jun 14, 2026
d04ed58
Update embed e2e for open mode: every cross-origin/PDF iframe promote…
erseco Jun 14, 2026
a9d39a3
Revert embed-policy admin UI (untranslated-strings i18n gate); keep o…
erseco Jun 14, 2026
b41dc7b
Mirror canonical embed hardening: trailing-dot gate, dead-code, Vitest
erseco Jun 17, 2026
f0a193b
Merge remote-tracking branch 'origin/main' into feature/secure-iframe…
erseco Jun 18, 2026
1db0efc
Merge branch 'main' into feature/secure-iframe-sandbox
erseco Jun 26, 2026
0ce4313
Merge remote-tracking branch 'origin/main' into feature/secure-iframe…
erseco Jun 29, 2026
0a3ee17
feat(secure-iframe): id-only embed channel + parent media host (DEC-0…
erseco Jun 29, 2026
373a058
fix(secure-iframe): sandbox cross-origin PDF players + relay teardown…
erseco Jun 29, 2026
0dfd1bb
feat(secure-iframe): always opaque + strict CSP/embed defaults; dev-o…
erseco Jun 29, 2026
038c860
fix(playground): reliably force legacy in the Playground; drop the al…
erseco Jun 30, 2026
46f6959
fix(i18n): move the dev-only escape-hatch warning to the Playground m…
erseco Jun 30, 2026
f5b8e56
test(iframe): assert the removed legacy option stays opaque; trim Pla…
erseco Jun 30, 2026
6ce3599
docs(embed): repoint embedder header to eXe core as the canonical source
erseco Jul 2, 2026
5064009
feat(preview): host-served opaque HTTP preview serving contract (refe…
erseco Jul 6, 2026
f45db76
fix(preview): satisfy WordPress coding standards (WPCS)
erseco Jul 6, 2026
2dedda8
Merge remote-tracking branch 'origin/main' into feature/secure-iframe…
erseco Jul 6, 2026
552cb69
fix(preview): use plain // comments for the route list (WPCS)
erseco Jul 6, 2026
e8e9533
Merge branch 'main' into feature/secure-iframe-sandbox
erseco Jul 6, 2026
a444f07
Merge branch 'main' of github.com:exelearning/wp-exelearning into fea…
erseco Jul 9, 2026
68b60ae
Merge branch 'main' of github.com:exelearning/wp-exelearning into fea…
erseco Jul 10, 2026
afcd0b9
feat(preview): file-backed session store + fixed-resource resolver (c…
erseco Jul 11, 2026
ebfe892
feat(preview): rework preview proxy to serving contract v2 and wire i…
erseco Jul 11, 2026
86c5b75
feat(preview): reclaim idle preview sessions via WP-Cron
erseco Jul 11, 2026
e97dc70
docs(preview): rewrite host serving-contract mirror to v2
erseco Jul 11, 2026
035a370
test(preview): unit tests + conformance vector replay for serving con…
erseco Jul 11, 2026
bfe8fa7
fix(embed): mirror core shim geometry re-report + relay drift check
erseco Jul 11, 2026
0098efd
fix(preview): deny direct web access to the preview session store
erseco Jul 11, 2026
75647bb
fix(preview): reject a revision/assets batch with a failed upload part
erseco Jul 11, 2026
90474df
fix(embed): mirror relay dispose() + idempotent init (drift-timer tea…
erseco Jul 11, 2026
083029d
i18n(preview): translate the preview-cleanup cron label
erseco Jul 11, 2026
8fbd029
refactor(preview): reduce PHPMD complexity in preview proxy/store
erseco Jul 11, 2026
ae824bc
Merge remote-tracking branch 'origin/main' into feature/secure-iframe…
erseco Jul 11, 2026
2a67d29
style(preview): satisfy WordPress Coding Standards (phpcs)
erseco Jul 11, 2026
d1b58e1
build(phpcs): anchor the uploads exclude-pattern to close the local f…
erseco Jul 11, 2026
c771805
i18n: regenerate .pot/.po/.mo to match current source
erseco Jul 11, 2026
8d3b0f3
feat(preview): split the HTTP adapter into serving + management contr…
erseco Jul 11, 2026
4399bd5
feat(editor): wire the previewHttp transport and drop the Service Wor…
erseco Jul 11, 2026
334e0cc
docs(preview): normalized two-URL previewHttp model + serving deltas
erseco Jul 11, 2026
b685d82
i18n: regenerate .pot/.po/.mo for the preview permalink admin notice
erseco Jul 11, 2026
e528eaf
docs(preview): note the X-WP-Nonce lifetime for long editing sessions
erseco Jul 11, 2026
09ba0df
fix(preview): canonical Range ordering + relative bare-root redirect;…
erseco Jul 11, 2026
a8eb535
fix(preview): drop trailing semicolon from sandbox CSP for byte-ident…
erseco Jul 11, 2026
c1b287e
fix(editor): neutralize Service Worker registration during the pre-HT…
erseco Jul 11, 2026
11ac94f
fix(preview): ship an nginx deny snippet for the preview session store
erseco Jul 11, 2026
44618a1
docs(preview): serving deltas, CSP byte-identity, nginx guard, SW int…
erseco Jul 11, 2026
643da31
fix(preview): never index or publish a failed store write (contract §5)
erseco Jul 11, 2026
30800a8
fix(preview): detect short writes + cover prior-revision-survives (co…
erseco Jul 11, 2026
040b063
fix(preview): use a private site-scoped session store
erseco Jul 12, 2026
062f759
test(preview): verify private default session storage
erseco Jul 12, 2026
3697029
docs(preview): align contract with private storage
erseco Jul 12, 2026
295ad51
Merge remote-tracking branch 'origin/main' into feature/secure-iframe…
erseco Jul 21, 2026
3dfdd8d
Merge remote-tracking branch 'origin/main' into feature/secure-iframe…
erseco Jul 21, 2026
75560c4
Merge remote-tracking branch 'origin/main' into feature/secure-iframe…
erseco Jul 22, 2026
67534fd
Add investigation about opfs
erseco Jul 22, 2026
423f670
feat(preview): sandbox non-HTML content and show an embed notice on t…
erseco Jul 22, 2026
d18e8e6
fix(editor): open the in-page editor modal from the attachment meta box
erseco Jul 22, 2026
90fdd13
refactor(media-library): match native WordPress attachment UI
erseco Jul 22, 2026
55070da
i18n: update catalogs for the content notice and media UI strings
erseco Jul 22, 2026
b0c5a02
Make the preview snapshot limits configurable and align them with upload
erseco Jul 24, 2026
e6957e7
Remove the superseded protocol-v2 preview classes and unbreak the linter
erseco Jul 24, 2026
39ac1d5
Show a loading spinner while an embedded package paints
erseco Jul 25, 2026
a5c3e5d
Simplify the embed loader and drop stale naming after review
erseco Jul 25, 2026
3253b2d
Remove the dead protocol-v2 preview config and an orphan admin class
erseco Jul 25, 2026
a77e861
Add the snapshot preview proxy that the plugin already requires
erseco Jul 25, 2026
cc331d6
Merge main: bundle-only editor (ADR-0002)
erseco Jul 25, 2026
0f5d2c3
Split ZIP inspection out of the preview snapshot store
erseco Jul 25, 2026
f297cd9
Merge remote-tracking branch 'origin/main' into feature/secure-iframe…
erseco Jul 25, 2026
dbc6693
Rewrite the preview contract doc to describe what the plugin does
erseco Jul 25, 2026
11726b4
feat(preview): make snapshot media seekable with an ETag and Range tier
erseco Jul 25, 2026
eb0abcd
refactor(preview): split parse_range into its two range forms
erseco Jul 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
9 changes: 8 additions & 1 deletion .phpcs.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,16 @@
<exclude-pattern>/tests/*</exclude-pattern>
<exclude-pattern>/includes/vendor/*</exclude-pattern>
<exclude-pattern>/dist/*</exclude-pattern>
<exclude-pattern>/exelearning/*</exclude-pattern>
<!-- Only the WordPress uploads extraction dir, path-anchored. A bare
`/exelearning/*` matched ANY checkout path containing an `exelearning/`
segment, silently excluding the whole plugin from local phpcs runs. -->
<exclude-pattern>*/wp-content/uploads/exelearning/*</exclude-pattern>
<!-- Standalone developer CLI tooling; not shipped (see .distignore). -->
<exclude-pattern>/bin/*</exclude-pattern>
<!-- Local, git-ignored scratch space for manual test seeds and Playwright
reports. It never reaches CI, so linting it only breaks `make lint`
on the developer's machine. -->
<exclude-pattern>/artifacts/*</exclude-pattern>

<arg name="extensions" value="php"/>
<arg name="colors"/>
Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
130 changes: 41 additions & 89 deletions admin/views/editor-bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 </head> tag.
// phpcs:disable WordPress.WP.EnqueuedResources.NonEnqueuedScript -- Standalone HTML page output, not a WordPress template.
$exelearning_wp_config_script = sprintf(
Expand Down Expand Up @@ -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",
Expand All @@ -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]);
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -538,10 +477,23 @@ function normalizeEditorAssetUrl(url) {
// Insert config script and styles before </head>.
$exelearning_template = str_replace( '</head>', $exelearning_wp_config_script . $exelearning_page_styles . '</head>', $exelearning_template );

// Add <base> tag to set the base URL for all relative paths.
// This ensures paths like "files/perm/..." resolve to the static editor directory.
// Add <base> 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 <head>, before any editor script runs.
// phpcs:disable WordPress.WP.EnqueuedResources.NonEnqueuedScript -- Standalone HTML page output, not a WordPress template.
$exelearning_cache_purge_tag = '<script>' . ExeLearning_Editor::purge_stale_editor_caches_script() . '</script>';
// phpcs:enable WordPress.WP.EnqueuedResources.NonEnqueuedScript
$exelearning_base_tag = sprintf( '<base href="%s/">', esc_url( $exelearning_editor_base_url ) );
$exelearning_template = preg_replace( '/(<head[^>]*>)/i', '$1' . $exelearning_base_tag, $exelearning_template );
$exelearning_template = preg_replace(
'/(<head[^>]*>)/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.
Expand Down
50 changes: 50 additions & 0 deletions assets/css/exelearning.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
Expand Down
Loading