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 diff --git a/.github/workflows/playground-preview.yml b/.github/workflows/playground-preview.yml index e4e63da..be33b4a 100644 --- a/.github/workflows/playground-preview.yml +++ b/.github/workflows/playground-preview.yml @@ -65,10 +65,24 @@ jobs: echo "version=pr-${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT" fi - # Pulls the prebuilt static editor from the latest exelearning/exelearning - # release (e.g. exelearning-static-vX.Y.Z.zip) into js/editor/ — no bun - # build. `make package-zip` then bundles it. `clean` keeps js/editor/. - - name: Download the eXeLearning static editor (latest release) + # Resolve the latest *stable* eXeLearning editor tag once, so the + # download and the version reported in the PR comment stay in sync. + - name: Resolve eXeLearning editor version + id: editor + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + REF=$(gh api repos/exelearning/exelearning/releases/latest --jq '.tag_name') + echo "ref=$REF" >> "$GITHUB_OUTPUT" + echo "eXeLearning editor: $REF" + + # Pulls the prebuilt static editor from that release (e.g. + # exelearning-static-vX.Y.Z.zip) into js/editor/ — no bun build. + # `make package-zip` then bundles it. `clean` keeps js/editor/. + - name: Download the eXeLearning static editor + env: + EXELEARNING_EDITOR_REF: ${{ steps.editor.outputs.ref }} run: make download-editor - name: Build the playground ZIP @@ -117,27 +131,25 @@ jobs: uses: actions/github-script@v7 env: PREVIEW_URL: ${{ steps.link.outputs.url }} - PREVIEW_TAG: ${{ steps.id.outputs.tag }} + PREVIEW_EDITOR: ${{ steps.editor.outputs.ref }} with: script: | const marker = ''; const url = process.env.PREVIEW_URL; - const tag = process.env.PREVIEW_TAG; + const editor = process.env.PREVIEW_EDITOR; + const button = 'https://raw.githubusercontent.com/ateeducacion/nextcloud-playground/refs/heads/main/assets/playground-preview-button.svg'; const body = [ marker, - '### ▶️ Preview this PR in the Nextcloud Playground', + '### Preview this PR in the Nextcloud Playground', '', - `[**Open this PR in the Nextcloud Playground**](${url})`, + `Open this PR in the Nextcloud Playground`, '', "A fresh Nextcloud boots in your browser with this branch's " + - '`exelearning` app installed and enabled (log in as `admin` / `admin`, ' + - 'then upload an `.elpx` in Files).', - '', - `Built artifact: \`exelearning.zip\` on the \`${tag}\` prerelease.`, + '`exelearning` app installed and enabled (log in as `admin` / `admin`). ' + + 'Two sample `.elpx` are seeded under `exelearning-samples/` in Files — ' + + 'click one to open the viewer.', '', - '> The viewer relies on a scoped Service Worker; some viewer features ' + - "may be limited inside the playground's own Service Worker. Core " + - 'install, Files and the app UI work.', + `Bundled eXeLearning editor: \`${editor}\` (latest stable release).`, ].join('\n'); const { owner, repo } = context.repo; const issue_number = context.payload.pull_request.number; diff --git a/README.md b/README.md index 126764e..2858530 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # nextcloud-exelearning [![CI](https://github.com/exelearning/nextcloud-exelearning/actions/workflows/ci.yml/badge.svg)](https://github.com/exelearning/nextcloud-exelearning/actions/workflows/ci.yml) -[![Open in Nextcloud Playground](https://img.shields.io/badge/Nextcloud%20Playground-Open-0082c9?logo=nextcloud&logoColor=white)](https://ateeducacion.github.io/nextcloud-playground/?blueprint-url=https://raw.githubusercontent.com/exelearning/nextcloud-exelearning/main/blueprint.json) + +Open in Nextcloud Playground Preview and edit [eXeLearning](https://exelearning.net/) `.elpx` packages directly inside Nextcloud Files. diff --git a/blueprint.json b/blueprint.json index a1fa879..ea29809 100644 --- a/blueprint.json +++ b/blueprint.json @@ -17,6 +17,8 @@ "email": "admin@example.com" }, "steps": [ + { "step": "disableApp", "app": "firstrunwizard" }, + { "step": "setConfig", "app": "core", "key": "whatsNewEnabled", "value": "no" }, { "step": "installApp", "appId": "exelearning", diff --git a/lib/Controller/EditorController.php b/lib/Controller/EditorController.php index a403a4b..4432022 100644 --- a/lib/Controller/EditorController.php +++ b/lib/Controller/EditorController.php @@ -228,12 +228,17 @@ public function iframe(): DataDisplayResponse|DataResponse { } $editorBaseHref = rtrim($this->urlGenerator->linkTo(Application::APP_ID, ''), '/') . '/js/editor/'; - $parentOrigin = $this->request->getServerProtocol() . '://' . $this->request->getServerHost(); - $config = json_encode([ - 'basePath' => rtrim($editorBaseHref, '/'), - 'parentOrigin' => $parentOrigin, - 'trustedOrigins' => [$parentOrigin], + // `basePath`, `parentOrigin` and `trustedOrigins` are derived client-side + // from the live document base + window origin rather than baked in here. + // When Nextcloud is served under a scoped sub-path (e.g. the browser + // Playground rewrites `` to the scoped URL), a server-computed + // basePath would carry an empty webroot and the editor's ResourceFetcher + // would load its theme/content bundles from an unscoped path that 404s on + // save. Deriving from `document.baseURI` (which equals the scoped + // `` at runtime) keeps it correct in both a normal install and + // under a scoped path. + $staticConfig = json_encode([ 'hideUI' => (object)[ 'fileMenu' => true, 'saveButton' => true, @@ -244,8 +249,14 @@ public function iframe(): DataDisplayResponse|DataResponse { ], ], JSON_UNESCAPED_SLASHES); - $headInject = '' - . ''; + $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); diff --git a/src/editor/editor-page.ts b/src/editor/editor-page.ts index fee248b..571960d 100644 --- a/src/editor/editor-page.ts +++ b/src/editor/editor-page.ts @@ -27,15 +27,6 @@ if (typeof window !== 'undefined' && window.OC?.appswebroots?.exelearning) { __webpack_public_path__ = `${window.OC.appswebroots.exelearning}/js/` } -/** - * - */ -function appWebRoot(): string { - // Falls back to the canonical /apps path so unit tests and stripped-down - // pages without OC.appswebroots still produce a sensible URL. - return window.OC?.appswebroots?.exelearning ?? '/apps/exelearning' -} - interface InitialFile { id: number name: string @@ -64,11 +55,12 @@ async function boot(): Promise { return } - // Defaults to the route URL; initial state can still override. - const editorIframeUrl = safeLoad( - 'editorIframeUrl', - `${appWebRoot()}/editor/iframe`, - ) + // Build the iframe URL client-side so it carries the live webroot. The + // server-rendered initial-state value is computed with an empty webroot when + // Nextcloud runs under a sub-path (e.g. the browser Playground), so using it + // as the iframe src would escape the scope and 404. generateUrl() is correct + // in both a normal install and under a scoped path. + const editorIframeUrl = generateUrl('/apps/exelearning/editor/iframe') const frame = new EditorFrame(root, { editorIframeUrl }) 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/view/view-page.ts b/src/view/view-page.ts index fcb2445..528cdce 100644 --- a/src/view/view-page.ts +++ b/src/view/view-page.ts @@ -1,6 +1,7 @@ // Same publicPath fix as main.ts. import { createApp } from 'vue' import { loadState } from '@nextcloud/initial-state' +import { generateUrl } from '@nextcloud/router' import ElpxViewPage from './ElpxViewPage.vue' @@ -47,7 +48,13 @@ function boot(): void { const file = safeLoad('file', null) const editorAvailable = safeLoad('editorAvailable', false) - const editorIframeUrl = safeLoad('editorIframeUrl', '/apps/exelearning/editor/iframe') + // Build the editor iframe URL client-side so it carries the correct webroot. + // The server-rendered value (via initial state) is computed with an empty + // webroot when Nextcloud is served under a sub-path (e.g. the browser + // Playground scopes everything under /playground//…); using it as the + // iframe src verbatim would escape that scope and 404. generateUrl() resolves + // against the live OC.webroot, which is correct in both cases. + const editorIframeUrl = generateUrl('/apps/exelearning/editor/iframe') const initialMode = safeLoad<'preview' | 'editor'>('initialMode', 'preview') createApp(ElpxViewPage, { file, editorAvailable, editorIframeUrl, initialMode }) 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 @@