From e4a607956d56370acc79cde41dcd150a07e4c257 Mon Sep 17 00:00:00 2001 From: erseco Date: Sat, 6 Jun 2026 09:00:50 +0100 Subject: [PATCH 1/6] feat(viewer): fall back to server-side asset serving when the SW can't register MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The viewer renders an .elpx by registering a scoped Service Worker that streams the package's internal assets from memory. That can't work when Nextcloud is embedded in an origin already owned by a controlling Service Worker (e.g. the Nextcloud Playground): the browser fetches a worker's script straight from the network, bypassing the controlling SW, so the virtual /apps/exelearning/sw.js route 404s and registration throws. When registration fails, fall back to the existing server-side AssetController (/apps/exelearning/asset//), which extracts and streams each entry from the stored package — no client Service Worker needed. Requires the canonical file id; otherwise the original error is surfaced as before. - paths: buildAssetUrl() + ASSET_PREFIX (+ tests) - iframe-renderer: extract buildSandboxedIframe() shared by both paths - ElpxViewer: try SW, catch -> attachServerIframe() --- src/elpx/iframe-renderer.ts | 28 +++++++++++++++------ src/elpx/paths.ts | 25 +++++++++++++++++++ src/viewer/ElpxViewer.vue | 49 ++++++++++++++++++++++++++++++------- tests/js/paths.test.ts | 21 ++++++++++++++++ 4 files changed, 107 insertions(+), 16 deletions(-) diff --git a/src/elpx/iframe-renderer.ts b/src/elpx/iframe-renderer.ts index 5b30ffb..3ff39a9 100644 --- a/src/elpx/iframe-renderer.ts +++ b/src/elpx/iframe-renderer.ts @@ -33,19 +33,20 @@ export interface IframeOptions { } /** - * Builds the sandboxed iframe that renders a registered package session. - * The `src` is built from the runtime base + sessionId + index entry; the - * Service Worker fulfils requests under that scope from in-memory bytes. - * @param options Runtime base, sessionId, index entry and accessible title. + * Builds a sandboxed iframe pointed at an already-resolved `src`. Shared by the + * Service Worker path ({@link createPackageIframe}) and the server-side asset + * fallback so both get identical sandbox flags and external-link rewiring. + * @param src Fully-resolved URL the iframe should load. + * @param title Accessible title for the iframe. */ -export function createPackageIframe(options: IframeOptions): HTMLIFrameElement { +export function buildSandboxedIframe(src: string, title: string): HTMLIFrameElement { const iframe = document.createElement('iframe') iframe.className = 'exelearning-viewer__iframe' - iframe.title = options.title + iframe.title = title iframe.setAttribute('sandbox', SANDBOX_FLAGS.join(' ')) iframe.setAttribute('allow', IFRAME_ALLOW) iframe.setAttribute('referrerpolicy', 'no-referrer') - iframe.src = buildRuntimeUrl(options.runtimeBase, options.sessionId, options.indexEntry) + iframe.src = src iframe.addEventListener('load', () => { try { rewireExternalLinks(iframe) @@ -57,6 +58,19 @@ export function createPackageIframe(options: IframeOptions): HTMLIFrameElement { return iframe } +/** + * Builds the sandboxed iframe that renders a registered package session. + * The `src` is built from the runtime base + sessionId + index entry; the + * Service Worker fulfils requests under that scope from in-memory bytes. + * @param options Runtime base, sessionId, index entry and accessible title. + */ +export function createPackageIframe(options: IframeOptions): HTMLIFrameElement { + return buildSandboxedIframe( + buildRuntimeUrl(options.runtimeBase, options.sessionId, options.indexEntry), + options.title, + ) +} + /** * Forces every external (`scheme:` or `//host`) link inside the iframe to * open in a new tab with `noopener noreferrer`, so the Viewer modal never diff --git a/src/elpx/paths.ts b/src/elpx/paths.ts index 94cf84e..fd6efe4 100644 --- a/src/elpx/paths.ts +++ b/src/elpx/paths.ts @@ -6,6 +6,7 @@ */ export const RUNTIME_PREFIX = '/apps/exelearning/runtime' +export const ASSET_PREFIX = '/apps/exelearning/asset' const PROTOCOL_LIKE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/ @@ -106,6 +107,30 @@ export function buildRuntimeUrl(base: string, sessionId: string, entry: string): .join('/')}` } +/** + * Builds an iframe-loadable URL for an entry served by the **server-side** + * AssetController. Used as a fallback when the runtime Service Worker can't be + * registered (e.g. Nextcloud embedded in another origin, like the Playground, + * where the browser fetches the SW script straight from the network and 404s). + * + * `fileId` is the Nextcloud file id; the server re-checks the user can read it + * and extracts the requested entry from the stored package on demand. + * @param base Asset base URL (typically `generateUrl(ASSET_PREFIX)`). + * @param fileId Nextcloud file id of the `.elpx` package. + * @param entry Normalised entry path inside the package. + */ +export function buildAssetUrl(base: string, fileId: number, entry: string): string { + const normalized = normalizeEntryPath(entry) + if (normalized === null) { + throw new Error(`Refusing to build asset URL for unsafe entry: ${entry}`) + } + const cleanBase = base.replace(/\/+$/, '') + return `${cleanBase}/${encodeURIComponent(String(fileId))}/${normalized + .split('/') + .map(encodeURIComponent) + .join('/')}` +} + /** * Parses a runtime URL produced by {@link buildRuntimeUrl} back into its * session and entry components. The base path must match RUNTIME_PREFIX. diff --git a/src/viewer/ElpxViewer.vue b/src/viewer/ElpxViewer.vue index b552bf9..49f3aba 100644 --- a/src/viewer/ElpxViewer.vue +++ b/src/viewer/ElpxViewer.vue @@ -34,6 +34,7 @@ '; + $configScript = ''; + + $headInject = '' . $configScript; if (preg_match('/]*>/i', $html, $m, PREG_OFFSET_CAPTURE)) { $pos = $m[0][1] + strlen($m[0][0]); $html = substr($html, 0, $pos) . $headInject . substr($html, $pos); From f71ea350061f0d1de34e093cae2957c876dff038 Mon Sep 17 00:00:00 2001 From: erseco Date: Sat, 6 Jun 2026 10:49:13 +0100 Subject: [PATCH 6/6] fix(dist): stop stripping the editor's bundle zips from the package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundled eXeLearning editor ships its themes and content CSS as js/editor/bundles/*.zip (themes/base.zip, content-css.zip, …), which the editor's ResourceFetcher loads when exporting on Save. .distignore excluded `*.zip` unanchored, so rsync stripped these nested bundles from the distribution package — the editor then 404s on Save ("Failed to fetch content/css/base.css"). This affected both the playground preview and any real packaged release (make up copies js/ directly, so it only showed in packaged builds). Anchor the excludes to the repo root (`/*.zip`, `/*.tar.gz`) so only the top-level build artifacts are stripped, not the editor's runtime bundles. --- .distignore | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.distignore b/.distignore index efb14b8..fdc801e 100644 --- a/.distignore +++ b/.distignore @@ -63,11 +63,15 @@ coverage *.log # --- Build / release outputs (recreated by `make package`) -------------- +# Anchor the archive excludes to the repo root with a leading slash: the +# bundled eXeLearning editor ships its themes/content as +# `js/editor/bundles/*.zip` (needed for the editor's Save/export), and an +# unanchored `*.zip` would strip them from the package, breaking Save. build dist release -*.tar.gz -*.zip +/*.tar.gz +/*.zip # --- eXeLearning editor source clone ------------------------------------ # The built editor (downloaded or compiled) lives under js/editor/ and is