Opaque-origin published viewer and opt-in opaque editor preview - #56
Opaque-origin published viewer and opt-in opaque editor preview#56erseco wants to merge 81 commits into
Conversation
The frontend shortcode and Gutenberg block embedded .elpx content with sandbox="allow-scripts allow-same-origin allow-popups", served same-origin. With allow-same-origin the arbitrary author HTML/JS can read the WordPress page's cookies/DOM and reach window.parent. Add an exelearning_iframe_sandbox_mode option (secure default | legacy): secure drops allow-same-origin so the content runs in an opaque origin and is isolated from the page; legacy restores the previous behaviour for environments that need it (e.g. WordPress Playground, whose service worker only serves same-origin documents). A single ExeLearning_Iframe_Sandbox helper owns the option and the per-mode sandbox tokens, consumed by both the shortcode and the block. Teacher mode used same-origin contentDocument access, which cannot run against an opaque iframe; in secure mode the desired state is carried on the iframe src (exe-teacher / exe-teacher-toggler) and the content proxy applies it server-side (hide-toggler style and mode-teacher class), so no parent-to-iframe DOM access is involved. The legacy contentDocument path is kept for legacy mode. No CSP change is needed: the proxy's existing default-src 'self' resolves to the serving origin and loads same-host subresources under the opaque origin (verified in a browser). An admin setting under Settings -> eXeLearning lets admins switch modes, and the Playground blueprint forces legacy. Adds unit tests for the helper, the shortcode and block sandbox tokens per mode, the secure teacher-mode src params, and the proxy's server-side teacher-mode injection.
Test in WordPress PlaygroundTest the plugin with the code from this branch:
|
Adds the new "Security" settings card strings to the POT and translates them in all maintained locales (ca, ca_valencia, de_DE, eo, es_ES, eu, gl_ES, it_IT, pt_PT, ro_RO) so the untranslated-strings check passes. Also drops the redundant card subtitle and shortens the field help text.
… hardening)
Even with the secure iframe, the proxied content is served same-origin with an
executable CSP, so opening the raw /wp-json/exelearning/v1/content/{hash}/index.html URL
top-level (e.g. manual navigation) would run author JS as the WordPress origin. Add a
`sandbox allow-scripts allow-popups` CSP directive for HTML in secure mode so the
document keeps an opaque origin however it is loaded; legacy is unchanged. Extracted a
testable build_html_csp() helper.
Verified in wp-env: opening the raw content URL top-level now reports
window.origin === 'null' and document.cookie throws SecurityError, while the content
still renders.
Live verification (secure mode, default)Brought this branch up with
By the symmetry of the same-origin policy, the author content cannot read the WordPress DOM, cookies or nonce, nor reach The backing content-proxy hardening checks out: HTML responses carry External-embed compatibility (worth a docs note). Because the content runs in an opaque origin and
This is an inherent trade-off of opaque-origin isolation, not a bug — just worth documenting for authors. Note on #54 (asset proxy): both PRs touch |
In secure mode the .elpx content runs in an opaque-origin sandbox, so cross-origin players (YouTube/Vimeo) and PDFs render blank (the sandbox flag propagates to nested iframes; Chrome also blocks its PDF viewer without allow-same-origin). Promote those embeds to the embedding page: the content proxy injects a shim that replaces whitelisted-video / .pdf iframes with placeholders and reports their geometry via postMessage; a relay enqueued on the shortcode/block page validates + rebuilds the URL and overlays the real player inline over each placeholder. - assets/js/exe-embed-shim.js / exe-embed-relay.js: the shared shim + relay. - class-content-proxy.php: inject the shim into served HTML (secure only). - class-iframe-sandbox.php: embed_whitelist() + enqueue_embed_relay(). - class-shortcodes.php / class-elp-upload-block.php: enqueue the relay (secure). PDFs: local package PDFs always render; any https .pdf renders; same-origin PDFs must belong to this package (served as application/pdf, never executable HTML). Tests in ContentProxyTest + IframeSandboxTest. Verified live in wp-env: YouTube, Vimeo and remote + local PDF render inline; a non-whitelisted iframe is not promoted.
The shim runs inside the content, so it resolves each iframe src against the content location and reports the ABSOLUTE URL. The parent relay resolves URLs against the host page, so a relative src (e.g. a locally-packaged PDF) would otherwise be rejected. Keeps parity with the mod_exelearning + omeka shim.
Add a self-contained Playwright/Firefox end-to-end test that loads the real exe-embed shim and relay against a static harness (no WordPress runtime needed) and verifies that whitelisted video and local PDF embeds are promoted to inline players on the parent page while other origins are rejected. Document the external-embed flow and how to run the test in the README.
…ywright config The dedicated playwright-embed.config.cjs runs the external-embed e2e in Firefox against its own static harness; the main config (chromium + wp-env) was auto-discovering it and failing. Exclude it via testIgnore. Also assert the non-whitelisted host with an exact hostname check (new URL().hostname) instead of a URL substring match.
…av fix Bring the WordPress embed relay/shim in line with the canonical mod_exelearning source: - Add Dailymotion and EducaMadrid/Mediateca de Madrid external-embed providers (allowlist hosts + per-provider canonical-URL validators in the relay). - Clamp the relayed player overlay to the placeholder box (clickjacking defence in depth; the overlay already clips with overflow:hidden). - Add allow-forms to the secure sandbox tokens, including the response-level CSP sandbox directive, so the form-based eXeLearning iDevices can submit inside the opaque sandbox. Align the legacy tokens with the canonical set. - Fix a lingering external embed when the eXe content pages to another view: the in-iframe shim restarts its embed-id counter per page, so a reused id could keep the previous page's player; tag each player with its URL and replace it when a reused id maps to a different URL. Update the IframeSandbox and ContentProxy unit tests for the new tokens and hosts.
…) + embed policy
Bring the WordPress embed relay/shim in line with the canonical mod_exelearning DEC-0061
change: drop the host allowlist for the default 'open' policy and promote any iframe whose
src is https AND cross-origin to the WordPress host (rejecting same-origin, sub/superdomains,
IP/loopback/local hosts and userinfo); a 'strict' policy keeps the allowlist + per-provider
reconstruction. The promoted video player is sandboxed (allow-scripts allow-same-origin
allow-popups allow-forms allow-presentation; no top-navigation/modals) so an arbitrary embed
cannot redirect the tab while the cross-origin provider still renders; PDFs stay unsandboxed.
Add the D1 same-origin-landing guard and the D2 forged-message defence (promoted players
tagged data-exe-embed-player, excluded from the content-source lookup).
Add an exelearning_embed_mode option (open default, fail-safe to strict) + an "External embed
policy" select in the admin Security settings, inject {mode, whitelist} into the relay config,
and update the IframeSandbox tests. Mirrors mod's logic (no drift).
…pen default + exelearning_embed_mode option The 10 maintained .po files require every string translated; the new select labels were untranslated and no canonical pot regeneration is available here. The embed policy still resolves from the exelearning_embed_mode option (default open), so the feature is unchanged; a localized UI can follow.
- Relay/shim: port the trailing-dot FQDN-root host normalization (normalizeHost) so the served host in 'host.' form is treated as same-host and not promoted as a cross-origin player. - Relay: hoist the content-iframe rect read out of the per-embed loop (one reflow per sync) and adopt the canonical dual-export tail (Node-requireable for tests; browser auto-run unchanged). - Content proxy: drop the unused window.__exeEmbedWhitelist injection; Referrer-Policy same-origin -> no-referrer on served files. - Sandbox: ship the embed whitelist only in strict mode. - Add a Vitest relay unit suite (happy-dom) covering the structural gate, including the trailing-dot cases.
…-sandbox # Conflicts: # languages/exelearning-ca.mo # languages/exelearning-ca.po # languages/exelearning-ca_valencia.mo # languages/exelearning-ca_valencia.po # languages/exelearning-de_DE.mo # languages/exelearning-de_DE.po # languages/exelearning-eo.mo # languages/exelearning-eo.po # languages/exelearning-es_ES.mo # languages/exelearning-es_ES.po # languages/exelearning-eu.mo # languages/exelearning-eu.po # languages/exelearning-gl_ES.mo # languages/exelearning-gl_ES.po # languages/exelearning-it_IT.mo # languages/exelearning-it_IT.po # languages/exelearning-pt_PT.mo # languages/exelearning-pt_PT.po # languages/exelearning-ro_RO.mo # languages/exelearning-ro_RO.po # languages/exelearning.pot # tests/unit/ContentProxyTest.php
Reconcile teacher-mode handling with main (#58). main retired host-side CSS/JS injection in favour of the package's own ?exe-teacher=1 URL parameter, so the secure-iframe branch is aligned to the same contract: - public/class-shortcodes.php, includes/class-elp-upload-block.php: keep the secure-mode embed-relay enqueue and opaque-iframe rendering, but drop the legacy teacher injection. The selector is offered by appending ?exe-teacher=1 to the iframe src when teacher_mode_visible (or the legacy teacher_mode attr) is on. This rides through the secure-mode content proxy too: the package reads its own location.search even under the opaque origin, so no host injection is needed. - includes/class-content-proxy.php: retire inject_teacher_mode() and the exe-teacher-toggler / exe-teacher server-side rewriting. It injected the exact #teacher-mode-toggler-wrapper CSS that #58 retired and, worse, auto-activated mode-teacher on ?exe-teacher=1 — which contradicts core (the parameter only offers the selector, it never auto-reveals). Keep inject_embed_shim(). - Tests: update ShortcodesTest / ElpUploadBlockTest secure-mode cases to assert the param-based contract (exe-teacher=1, no exe-teacher-toggler, no contentDocument) and drop the ContentProxyTest inject_teacher_mode cases. Other conflicts: - package.json: union — main's @playwright/test and @wordpress/env bumps plus the branch's vitest + happy-dom (used by tests/js). Lockfile regenerated. - languages: msgcat union of both sides (main's strings authoritative, the branch's secure-mode admin strings preserved), .mo recompiled, JED .json taken from main (elp-upload.js is byte-identical to main), .pot unioned. CI regenerates references. The admin block-editor live preview (assets/js/elp-upload.js) keeps main's same-origin preview behaviour unchanged.
…067) Mirror the id-only channel (extractProvider/reconstructProvider) into the embed shim/relay. Add exe-media-policy.js + exe-media-host.js (vendored from mod_exelearning) and enqueue_media_host() in ExeLearning_Iframe_Sandbox, called from the block + shortcode renderers: parent-side host for the interactive-video iDevice via raw postMessage (no YouTube IFrame API/Vimeo SDK). No-op in legacy.
… on re-open M-3: makePlayer() rendered a cross-origin .pdf in a fully unsandboxed iframe, so an author-supplied https://evil/x.pdf serving HTML could top-navigate the host tab to a phishing page (reachable in both open and strict mode). Mirror the canonical Moodle 3-way branch: a same-origin package PDF stays unsandboxed (the browser PDF viewer needs it), a cross-origin PDF gets sandbox="allow-same-origin" (no allow-scripts, no allow-top-navigation). L-2: the modal media host appended a new <dialog> and overwrote session.adapter on every 'open' without tearing down the previous one, so repeated 'open' commands could stack modals and orphan provider players. openMedia() now discards the prior media/poll-timer/dialog first (single active media per session). Tests: cross-origin vs same-origin PDF sandbox; media-host single-active teardown.
…nly escape hatch
The content iframe is now always opaque-origin: the same-origin admin mode was removed.
A dev-only escape hatch (EXELEARNING_UNSAFE_LEGACY_IFRAME constant, default off, never in
the admin UI) restores same-origin only for environments that cannot serve opaque subframes
(the php-wasm WordPress Playground). It surfaces a loud admin warning when active.
- class-iframe-sandbox: mode() is always secure unless the unsafe constant is defined;
embed_mode() defaults to strict (open is opt-in); add a CSP profile (strict default,
exelearning_csp_profile filter -> compatible).
- content-proxy: build_html_csp() strict by default (no bare https: in script/img/media-src;
frame-src limited to the maintained providers) with a documented-weaker compatible profile.
- admin settings: replace the secure/legacy selector with a read-only status + escape-hatch
warning. blueprint.json defines the unsafe constant instead of update_option('...','legacy').
- Tests: legacy option ignored, strict embed/CSP defaults, compatible profile, escape hatch
off by default. Add testing-the-sandbox.elpx fixture.
NOTE: validated locally by php -l + vitest (28 JS tests). The PHPUnit + phpcs run in PR CI
(no composer/WP test harness here).
…ways-secure settings card The php-wasm Playground service worker only serves same-origin documents, so the opaque content iframe could not load its CSS/JS. The escape hatch is now delivered by a Playground-only mu-plugin (loads before all plugins, defines a real boolean EXELEARNING_UNSAFE_LEGACY_IFRAME) instead of defineWpConfigConsts, and is_unsafe_legacy() accepts any truthy value (filter_var). Also removes the read-only "always secure" Security card from the admin settings (it only stated the obvious); the dev-only escape-hatch warning now renders only when the hatch is active.
…u-plugin The admin-settings warning for the EXELEARNING_UNSAFE_LEGACY_IFRAME escape hatch was the only new translatable string in this PR, and the check-untranslated CI gate requires every languages/*.po to be fully translated — so it failed. The warning only ever shows when the dev-only hatch is active, which in practice is the WordPress Playground. Move it into the Playground-only mu-plugin (plain English, not scanned by make-pot) as an admin_notices banner, and drop render_security_section() from the shipped plugin. The hatch itself (is_unsafe_legacy) is unchanged and still covered by IframeSandboxTest.
…yground comment Two PHPUnit tests still drove the same-origin sandbox via the removed admin option (update_option(OPTION, MODE_LEGACY)) and asserted allow-same-origin. Always-opaque ignores that option, so they failed once the untranslated gate (which masked them) passed. Rewrite both as regression guards: the ignored legacy option must keep the content/block iframe opaque (no allow-same-origin); the dev-only constant hatch is the only same-origin path (covered by IframeSandboxTest). Also trim the Playground mu-plugin comment — the full php-wasm rationale now lives in the PR description.
eXeLearning core (public/app/common/exe_embed_bridge/) is now the canonical source for the promote-to-parent embed relay/shim; this copy mirrors it. Header comment only — no logic change (verified by core's scripts/check-embed-sync.mjs: no drift).
|
The external-embed bridge ( |
…rence) Mirror of the eXeLearning core canonical contract (exelearning/doc/development/preview-serving-contract.md): serve the editor preview of untrusted author content over an authless capability URL in an opaque origin, via this host's own cookieless serving primitive, so the preview gets real per-page URLs (working navigation + open-in-new-tab) instead of the srcdoc fallback. The sandbox-first CSP is emitted verbatim from core's previewCspHeader() on every scriptable document type (text/html, image/svg+xml, application/xml, application/xhtml+xml). Reference endpoint + docs; the session store, management API and tests are follow-up per repo.
Remove stray markdown (a code fence + wiring-notes prose) accidentally left after the class closing brace, convert the aligned route-list inline comment to a block comment, capitalize a docblock short description, and end the file with a single newline. phpcs --standard=.phpcs.xml.dist now clean.
…-sandbox # Conflicts: # languages/exelearning-ca.mo # languages/exelearning-ca.po # languages/exelearning-ca_valencia.mo # languages/exelearning-ca_valencia.po # languages/exelearning-de_DE.mo # languages/exelearning-de_DE.po # languages/exelearning-eo.mo # languages/exelearning-eo.po # languages/exelearning-es_ES.mo # languages/exelearning-es_ES.po # languages/exelearning-eu.mo # languages/exelearning-eu.po # languages/exelearning-gl_ES.mo # languages/exelearning-gl_ES.po # languages/exelearning-it_IT.mo # languages/exelearning-it_IT.po # languages/exelearning-pt_PT.mo # languages/exelearning-pt_PT.po # languages/exelearning-ro_RO.mo # languages/exelearning-ro_RO.po # package-lock.json # package.json # public/class-shortcodes.php
…-sandbox # Conflicts: # .phpcs.xml.dist # languages/exelearning-ca.po # languages/exelearning-ca_valencia.po # languages/exelearning-de_DE.po # languages/exelearning-eo.po # languages/exelearning-es_ES.po # languages/exelearning-eu.po # languages/exelearning-gl_ES.po # languages/exelearning-it_IT.po # languages/exelearning-pt_PT.po # languages/exelearning-ro_RO.po # languages/exelearning.pot
…-sandbox # Conflicts: # languages/exelearning-ca.mo # languages/exelearning-ca.po # languages/exelearning-ca_valencia.mo # languages/exelearning-ca_valencia.po # languages/exelearning-de_DE.mo # languages/exelearning-de_DE.po # languages/exelearning-eo.mo # languages/exelearning-eo.po # languages/exelearning-es_ES.mo # languages/exelearning-es_ES.po # languages/exelearning-eu.mo # languages/exelearning-eu.po # languages/exelearning-gl_ES.mo # languages/exelearning-gl_ES.po # languages/exelearning-it_IT.mo # languages/exelearning-it_IT.po # languages/exelearning-pt_PT.mo # languages/exelearning-pt_PT.po # languages/exelearning-ro_RO.mo # languages/exelearning-ro_RO.po # languages/exelearning.pot
Security evidence — opaque-sandbox containment (main vs. patch)Screenshots and a reusable test package demonstrating that the opaque-origin content viewer contains untrusted eXe content while legitimate embedded media keeps working. How this was produced (Playwright + wp-env): a malicious
The package embeds a safe, read-only isolation probe (15 checks, booleans only — never reads or transmits real cookie/token values, never navigates, no network POST) that renders its results in-frame: green = safe/contained, red = dangerous capability reached, gray = informational. 1. Isolation probe — the untrusted content trying to reach the host
On main the untrusted eXe content reads the host WordPress DOM and cookies. On the patch every origin-crossing vector is blocked. ( 2. Legitimate media still works under the patchThe test package is a 6-page eXe export, each page carrying a different cross-origin embed (YouTube-nocookie, Vimeo, Dailymotion, a generic cross-origin iframe, and an in-package PDF). Under the opaque sandbox the embed shim/relay promotes each to a parent-hosted sandboxed player, so videos play and the content stays contained: 3. Full probe panel (patch) — corrected colors + host takeover buttonsUnder the patch every escape vector is green ( Test packagesandbox-video-probe.zip — rename One-click reproduction (WordPress Playground, legacy same-origin): |
…op-level open Serve a response-level sandbox CSP for every scriptable type (PDF, SVG, XML), not only HTML — closing the gap where a PDF was served without a sandbox. When untrusted content is opened as a top-level navigation (Sec-Fetch-Dest: document), serve a friendly "this content lives inside a page" notice instead of running it, so untrusted markup never executes outside its embedding context.
The "Edit in eXeLearning" action in the attachment meta box and media modal navigated to a full page instead of opening the existing in-page editor modal. Bind a delegated handler so any .exelearning-edit-page-button[data-attachment-id] opens the modal (Save / Close) in place.
Align eXeLearning attachment previews with the native "Edit image" pattern: a large interactive preview in the two-column details modal, a compact scaled- down thumbnail plus a plain blue link in the narrow media selection sidebar, and a centered edit button in the meta box. Remove the duplicate edit button, the "open in new tab" links, the fullscreen control and the separate "eXeLearning Metadata" box. Enqueue the embed relay in the admin so embedded media renders inside admin previews.
The snapshot store hard-coded 100 MB / 5000 entries with no way to adjust
them, while the upload extractor already accepted 1 GB / 10000 entries via
`exelearning_max_extract_bytes` and `exelearning_max_extract_files`. A course
package that WordPress had accepted and extracted could therefore not be
previewed: real packages routinely reach 200-500 MB once their media counts,
and enabling external scripts on one failed closed every time.
Raise the defaults to match the upload extractor and expose both limits the
way the rest of the plugin does, with a constant for site owners and a filter
for code:
EXELEARNING_PREVIEW_MAX_BYTES / exelearning_preview_max_bytes
EXELEARNING_PREVIEW_MAX_FILES / exelearning_preview_max_files
A non-positive override falls back to the default, so the zip-bomb guard
cannot be switched off. The guard itself is unchanged: the ZIP is still
validated before extraction, and a rejected snapshot still fails closed to
the filtered preview.
The opaque snapshot proxy took over the preview routes and the v2 controller tests were already deleted, but the six implementation files were left on disk, deliberately unloaded. They were dead weight (~100 KB) that could no longer be exercised, and `class-preview-proxy.php` declared a second `ExeLearning_Preview_Proxy`, so PHPCS failed the whole run with Generic.Classes.DuplicateClassName before reaching anything else. Delete all six and update the loader comment. Nothing outside the group referenced them: the only external mention of `ExeLearning_Preview_Proxy` resolves to the snapshot class that stays. Also exclude the git-ignored `artifacts/` scratch directory from PHPCS. It holds manual test seeds and Playwright reports that never reach CI, so linting them only broke `make lint` locally. `make lint` is clean again.
The block renders an iframe that has to download, parse and lay out a whole package before anything appears, so a visitor sees an empty bordered frame for a noticeable moment and cannot tell whether it is loading or broken. Large packages make it worse. Cover that gap with a spinner overlaying the frame. It is revealed by the script itself, never server-side, so a visitor without JavaScript never gets an overlay that nothing could clear. It is announced with role="status" so it is not a silent animation, respects prefers-reduced-motion, and clears on the iframe's load event plus one animation frame, because the theme builds its navigation right after load and clearing immediately can uncover a frame that is still blank. A timeout is the backstop for an embed whose load event never fires.
Cleanup pass over the spinner work; no behaviour change intended. Move the loader from a per-instance inline <script> to one enqueued asset that binds every .exelearning-embed-loader on the page. Five embeds shipped five uncached copies of the same ~1.7 KB IIFE, each re-running the same lookups, and inline script is the first thing a hardened Content-Security- Policy drops. The plugin already ships this shape for the fullscreen button. Gate the spinner on intersection. The iframe is loading="lazy", so a block below the fold had not begun loading while its spinner was already running — it would have spun until the 20 s backstop over an idle frame. Also: drop the data-exe-loader attribute nothing read, move the spinner's static styling out of the .is-loading rule so only visibility depends on state, and rename service_worker_guard_script to purge_stale_editor_caches_script — it stopped guarding anything when the registration stub was removed and now only purges caches. The build recipe's NOTE about a patch that is no longer applied was changelog text with no code near it.
build_preview_http_config() still emitted protocolVersion 2 with the management and serving base URLs of the removed HTTP transport. The bootstrap never emits previewHttp and the bundled editor never reads it, so its only callers were its own two tests. The opaque preview now travels as a whole-project snapshot; pretty_permalinks_enabled() and its admin notice stay, because the snapshot serving route still needs them. While there, correct the doc block on purge_stale_editor_caches_script(): it still described the removed registration stub and an HTTP-v2 editor build that is never coming. public/views/elp-list.php declared Exelearning_Admin, a leftover from the plugin generator that no require_once reaches and nothing instantiates. It was also the last file failing the WordPress-standard file-naming sniff.
exelearning.php require_once's includes/class-exelearning-preview-proxy.php and ExeLearning::__construct instantiates ExeLearning_Preview_Proxy, but the file was never added to the index when the protocol-v2 classes came out, so the pushed tree fataled on load and PreviewSnapshotStoreTest referenced an undefined class. Local runs passed only because the file existed on disk. Bring in the companion changes the removal left half-done: - The activator/deactivator still scheduled and cleared a WP-Cron event keyed on ExeLearning_Preview_Proxy::CRON_HOOK. The snapshot store has no cron: it reclaims expired capabilities lazily on each request, so the activation hooks now do nothing and say why. - exe-media-host.js gains the closeAll() the editor needs, and the editor calls it before opening its modal so a promoted player in a top-layer <dialog> cannot float above it. - MediaLibraryTest drops the meta-box test the native-attachment-UI refactor superseded.
Everything outside languages/ merged cleanly. The catalogs conflicted in all ten locales because both sides regenerated them. Resolved as a union rather than a side: main's editor-bundle notices keep the translations main shipped, and this branch's preview/media strings keep theirs (msgcat --use-first ours theirs). Then regenerated the whole set from the merged source with composer make-translations, so the .pot no longer carries the removed installer strings. "Loading content…", added with the embed spinner, had never reached the catalogs; it is now translated in all ten locales, which is what the pipeline's untranslated guard requires.
Code scanning flagged extract() at an NPath complexity of 3084 against a threshold of 500 (alert #40); replace() was worse at 10368 and would have been the next alert. Both had grown into long straight-line methods where every guard multiplied the path count. Decomposing them in place was not enough: PHPMD sums complexity per class, so splitting a method only moves it, and the store went over the class threshold instead. The archive checks are a separate job anyway — the store writes a tree atomically, the inspector decides whether an archive may be written at all — so they move to ExeLearning_Preview_Zip_Inspector, which now owns the entry loop, the symlink test and the path rules. The store keeps max_files()/max_bytes() as the configuration surface and passes them in, so the inspector does not point back at it. replace() is now four named phases: authorize, stage, write metadata, publish. Behaviour is unchanged and the path rules were moved verbatim; the only visible difference is that a failed extractTo() no longer reports "must contain index.html", which was never what it meant. The store's file is clean under the CI ruleset; handle_upload() and ExeLearning_Content_Proxy stay flagged, both pre-existing on main.
The protocol-v2 classes came out but this document stayed, still opening with
"This plugin implements eXeLearning Preview Serving Contract v2" and documenting
a previewHttp block, layered resource revisions and a fixed-resource manifest —
none of which exist here any more.
Rewritten around the snapshot contract, and three claims corrected against the
code while doing it:
- the management routes carry the attachment id
(/preview-session/{attachmentId}[/{previewId}]), which the old text omitted;
- pretty permalinks are required for a practical reason the old text got wrong.
The configuration is NOT omitted under plain permalinks; it is emitted and an
admin notice is raised. What actually breaks is client-side: the editor
resolves {previewId}/index.html against servingBaseUrl, and resolving a
relative path against a ?rest_route= URL drops the query string;
- serving is no-store for everything. The ETag/Range tier described here is the
Moodle adapter's, not this one's.
The capability route sent every file as `Cache-Control: no-store` with no ETag and no `Accept-Ranges`, so a video or audio track the author put inside the project could not seek: the browser re-fetched from byte zero on each attempt, or refused to scrub at all. Every other asset also re-downloaded in full on each preview refresh. The three sibling adapters already serve a revalidating tier; this brings WordPress in line. A scriptable document keeps `no-store` — it is rewritten on every refresh and always sent whole. Everything else now revalidates with an ETag and supports `If-None-Match` (304) and a single Range (206/416). A 304 and a 416 send no body, and a 206 streams only its window, so nothing here reads more of a file than it is about to send. `readfile()` still handles a full response, which is the one thing this route already had right. The ETag is built from identity rather than from hashing bytes, which would defeat the point by reading the whole file to decide whether to send none of it. Path plus mtime plus size is not enough on its own: mtime has one-second granularity, so an author refreshing twice within the same second with an edit that keeps a file the same length would produce the same tag and be handed a 304 for the previous bytes. The snapshot directory's inode joins the tag — every publish renames a freshly built directory into place, so it always turns over. parse_range() and if_none_match_matches() are pure and unit-tested, including the contract's distinction between a range that is ignored (200) and one that is unsatisfiable (416).
904f0b5 to
11726b4
Compare
PHPMD flagged an NPath complexity of 864 against a threshold of 500. The method was deciding three separate things at once: whether the header is one this server honours at all, and then how a suffix range and an anchored range each resolve against the entity size. The guard and the dispatch stay in parse_range(); suffix_range() and offset_range() take one form each. Same behaviour — the range tests, including the ignored-versus-unsatisfiable distinction, pass unchanged.






Published viewer
Extracted packages render in a sandboxed opaque-origin iframe, so author HTML
and JavaScript cannot reach the WordPress page, its cookies or the REST nonce.
External video still plays: the shared shim demotes each provider iframe inside
the package and a relay on the trusted page overlays the real player.
EXELEARNING_UNSAFE_LEGACY_IFRAMEis a development-only escape hatch for thephp-wasm Playground. It is not an administration setting and never applies to the
editor preview.
Editor preview
Filtered by default. Enabling active content POSTs the project as one snapshot
to an authenticated route and serves it from an authless capability URL under a
sandbox CSP, with a 30-minute idle TTL:
Snapshots are capped at 1 GB and 10 000 entries, both overridable
(
EXELEARNING_PREVIEW_MAX_BYTES/exelearning_preview_max_bytes, and the_filesequivalents). Over the limit the grant fails closed and the filteredpreview stays. Archives are vetted before a byte is written: entry count,
declared uncompressed size, path traversal, reserved names and symlinks.
Also here
A loading indicator while an embedded package paints, and the attachment UI
aligned with native WordPress.