Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 3 additions & 1 deletion assets/js/elp-upload.js
Original file line number Diff line number Diff line change
Expand Up @@ -573,7 +573,9 @@
// allow-modals so the preview cannot raise "Leave site?"
// dialogs. (Isolating untrusted content in a separate origin
// is tracked as follow-up; see the proxy CSP for mitigation.)
sandbox: 'allow-scripts allow-same-origin allow-popups',
// allow-downloads lets the package's own .elpx download
// button save its file (exelearning/exelearning#2488).
sandbox: 'allow-scripts allow-same-origin allow-popups allow-downloads',
style: {
width: '100%',
height: '100%',
Expand Down
56 changes: 56 additions & 0 deletions assets/js/wp-exe-download.js
Original file line number Diff line number Diff line change
Expand Up @@ -349,12 +349,68 @@
} );
}

/**
* Point the package's own "Download .elpx" button at the original upload.
*
* The download-source-file iDevice's inline onclick calls the package's
* global downloadElpx(), which refetches every file of the package and
* rebuilds the ZIP in the browser. Inside the embed that never saves a file:
* the content CSP blocks the blob: workers fflate compresses in, and the
* sandbox drops downloads (exelearning/exelearning#2488). Packages exported
* before exelearning/exelearning#2196 also rebuild without content.xml. The
* toolbar already offers the original .elpx, so serve that instead.
*
* Only while this embed's toolbar offers an enabled .elpx item, and only
* while the frame is same-origin: with a cross-origin content origin the
* access throws and the package keeps its own download.
*
* @param {Element} frame Element whose load event fired.
*/
function routeContentDownload( frame ) {
if ( ! frame || frame.tagName !== 'IFRAME' || ! frame.classList.contains( 'exelearning-iframe' ) ) {
return;
}
var embed = frame.closest( '.exelearning-preview, .exelearning-block-frontend' );
var container = embed && embed.querySelector( '.exelearning-download[data-elp-url]' );
var item = container && container.querySelector( '[data-format="elpx"]:not(.exelearning-download__item--disabled)' );
if ( ! item ) {
return;
}
var params = {
format: 'elpx',
suffix: item.getAttribute( 'data-suffix' ) || '.elpx',
attachmentId: parseInt( container.getAttribute( 'data-attachment-id' ), 10 ),
elpUrl: container.getAttribute( 'data-elp-url' ),
slug: container.getAttribute( 'data-slug' ),
container: container,
};
try {
var win = frame.contentWindow;
if ( ! win || typeof win.downloadElpx !== 'function' ) {
return;
}
win.downloadElpx = function() {
return downloadFormat( params );
};
} catch ( e ) {
// Cross-origin frame: leave the package's own download in place.
}
}

// `load` does not bubble, but it is dispatched through the capture phase, so
// one listener sees every embed iframe, including later in-frame navigations.
document.addEventListener( 'load', function( event ) {
routeContentDownload( event.target );
}, true );

// Public API so the block editor can reuse the exact same export pipeline.
window.wpExeDownload = {
downloadFormat: downloadFormat,
};

function init() {
// Frames that finished loading before this script ran.
Array.prototype.forEach.call( document.querySelectorAll( 'iframe.exelearning-iframe' ), routeContentDownload );
document.addEventListener( 'click', onClick );
document.addEventListener( 'keydown', function( e ) {
if ( e.key === 'Escape' ) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
---
id: ADR-156-01
title: "Let the package's own .elpx download button work in embeds"
status: Proposed
date: 2026-09-29
tracking_issue: 156
deciders:
- "@erseco"
- "claude-code"
related:
prs: [156]
changes: []
adrs: []
external_refs:
- "https://github.com/exelearning/exelearning/issues/2488"
- "https://github.com/exelearning/exelearning/pull/2489"
- "https://github.com/exelearning/exelearning/pull/2196"
- "https://github.com/exelearning/omeka-s-exelearning/pull/63"
supersedes: []
superseded_by: []
ai_assistance:
tool: "Claude Code"
model: "claude-opus-5-5"
---

# ADR-156-01: Let the package's own .elpx download button work in embeds

## Context

eXeLearning packages can contain a *download-source-file* iDevice: a "Download
.elpx" button inside the content. Its inline `onclick` calls the package's global
`downloadElpx()` (`libs/exe_elpx_download/exe_elpx_download.js`). That function
refetches every file listed in `libs/elpx-manifest.js`, zips the files in the
browser with fflate, and clicks an `<a download>` inside the content document.

In an embed, that button never saved a file. It failed in two independent places:

1. **CSP.** `ExeLearning_Content_Proxy` served HTML with `script-src 'self'
'unsafe-inline' 'unsafe-eval'` and no `worker-src`. The async `fflate.zip()`
starts `blob:` workers and listens only for their messages. The workers are
blocked, the callback never fires, and the button stays at "Processing...
100%" (exelearning/exelearning#2488).
2. **Sandbox.** The block, shortcode, Media Library and Gutenberg preview iframes
use `sandbox="allow-scripts allow-same-origin allow-popups"`. Without
`allow-downloads`, Chrome drops any download the frame starts.

Rebuilding is also a poor substitute for the original upload. It refetches the
whole package and holds it in memory two to three times over. Packages exported
before exelearning/exelearning#2196 rebuild without `content.xml`, so the result
cannot be re-imported.

## Problem

How should the package's own "Download .elpx" button behave in a WordPress
embed? The answer must cover packages that are already published, whose script
cannot be changed.

## Decision drivers

- Fix content that is already published, without re-uploading it.
- Give users the same file the toolbar already offers, byte for byte.
- Respect embeds that do not offer the `.elpx` (`show_download` off, the
default).
- Do not widen what package JavaScript can do in the site origin.
- Keep working under a cross-origin `exelearning_content_origin`.

## Alternatives considered

### Option 1: Wait for the upstream script fix

exelearning/exelearning#2489 adds a `zipSync` fallback when workers are blocked.
It helps only packages exported after it ships, and the sandbox still drops the
download.

### Option 2: Relax the CSP and the sandbox only

Add `worker-src 'self' blob:` and `allow-downloads`. The rebuild then completes,
but it still refetches the whole package and may omit `content.xml`.

### Option 3: Serve the original from the parent, and relax the CSP and the sandbox as a fallback

While an embed's toolbar offers an enabled `.elpx` item, `wp-exe-download.js`
replaces the package's `downloadElpx()` with a function that downloads the
original attachment through the toolbar's own `downloadFormat()`. Everywhere else
the package's own rebuild runs, and Option 2's relaxations let it finish.

## Evidence

- The same CSP and sandbox reproduce on the Omeka S module: 34 `worker-src
blob:` violations and a button stuck at 100 %. With the fixed script, Chrome
logged "Download is disallowed … 'allow-downloads' is not set"
(exelearning/omeka-s-exelearning#63).
- With the routing in place on that site, the in-content button downloaded the
original, with the same SHA-256 as the upload.
- fflate 0.8.3 `zip()` starts a worker for each compressible file of 160 kB or
more, and its browser worker wrapper sets only `onmessage`.
- `includes/class-content-proxy.php`, `includes/class-elp-upload-block.php`,
`public/class-shortcodes.php`, `includes/integrations/class-media-library.php`
and `assets/js/elp-upload.js` at `7ec2e82`.

## Decision

We will take Option 3:

- `assets/js/wp-exe-download.js` routes `downloadElpx()` per embed, only while
that embed offers an enabled `.elpx` item. A capturing `load` listener
reapplies it on each page the frame navigates to. A cross-origin frame raises
an access error, which is caught.
- HTML content gets `worker-src 'self' blob:`.
- The embed iframes get `allow-downloads`.

Neither relaxation grants new capability. Package scripts already run with
`'unsafe-inline'` and `'unsafe-eval'` in the site origin and can start downloads
through the same-origin parent.

## Consequences

### Positive

- The in-content button works for every existing package: it gives the
original when the toolbar offers it, and a completed rebuild otherwise.
- There is no full refetch or in-memory rebuild in the common case.

### Negative

- The routing reaches into package globals. If a future eXeLearning renames
`downloadElpx`, the routing stops, and the package's own download still works.

### Neutral

- No change to the script-free SVG/XML policy, to extraction, or to the
`.htaccess` rules.

## Risks

- A package could redefine `downloadElpx` after `load`. Its own download would
then run instead of the original. Low severity.
- Moving content to an opaque or cross-origin frame in the future must keep
`allow-downloads` and a `worker-src blob:` allowance, or the fallback breaks.

## Validation

- `tests/js/wp_exe_download.test.js` covers the routing, including scoping,
disabled or missing `.elpx`, navigation, a cross-origin frame and several
embeds on one page.
- PHPUnit shortcode, block and Media Library tests pin `allow-downloads`.
- Manual check with a package that contains the download-source-file iDevice,
with `show_download` on and off.

## Follow-up work

- None required. Remove the routing if eXeLearning adds an explicit
host-download protocol.

## References

- exelearning/exelearning#2488, #2489 and #2196
- exelearning/omeka-s-exelearning#63 (same fix, with a live reproduction)
5 changes: 5 additions & 0 deletions includes/class-content-proxy.php
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,11 @@ private function send_headers( $mime_type, $file_size ) {
array(
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
// The package's download-source-file button rebuilds the .elpx
// with fflate, which compresses in blob: workers. Scripts here
// already run with 'unsafe-inline'/'unsafe-eval', so this adds
// no capability (exelearning/exelearning#2488).
"worker-src 'self' blob:",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"media-src 'self' data: blob: https:",
Expand Down
2 changes: 1 addition & 1 deletion includes/class-elp-upload-block.php
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,7 @@ class="exelearning-iframe"
style="width: 100%%; height: %dpx; border: 1px solid #ddd; border-radius: 4px;"
title="%s"
loading="lazy"
sandbox="allow-scripts allow-same-origin allow-popups"
sandbox="allow-scripts allow-same-origin allow-popups allow-downloads"
referrerpolicy="no-referrer"
></iframe>',
$this->build_preview_url( $data ),
Expand Down
2 changes: 1 addition & 1 deletion includes/integrations/class-media-library.php
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ public function render_preview_meta_box( $post ) {
$preview_url = ExeLearning_Content_Proxy::get_proxy_url( $directory );

echo '<div style="width: 100%; height: 600px; overflow: auto; margin-bottom: 15px;">';
echo '<iframe src="' . esc_url( $preview_url ) . '" style="width: 100%; height: 100%; border: none;" sandbox="allow-scripts allow-same-origin allow-popups" referrerpolicy="no-referrer"></iframe>';
echo '<iframe src="' . esc_url( $preview_url ) . '" style="width: 100%; height: 100%; border: none;" sandbox="allow-scripts allow-same-origin allow-popups allow-downloads" referrerpolicy="no-referrer"></iframe>';
echo '</div>';
echo '<p><a href="' . esc_url( $preview_url ) . '" target="_blank" rel="noopener noreferrer">' . esc_html__( 'Open in new tab', 'exelearning' ) . '</a></p>';
} else {
Expand Down
2 changes: 1 addition & 1 deletion public/class-shortcodes.php
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ class="exelearning-iframe"
title="%s"
loading="lazy"
allow="fullscreen"
sandbox="allow-scripts allow-same-origin allow-popups"
sandbox="allow-scripts allow-same-origin allow-popups allow-downloads"
referrerpolicy="no-referrer"
></iframe>',
$iframe_src_attr,
Expand Down
Loading
Loading