Skip to content
Open
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
8 changes: 6 additions & 2 deletions .distignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 27 additions & 15 deletions .github/workflows/playground-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = '<!-- playground-preview -->';
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})`,
`<a href="${url}" target="_blank" rel="noopener noreferrer"><img src="${button}" alt="Open this PR in the Nextcloud Playground" width="224"></a>`,
'',
"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;
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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)

<a href="https://ateeducacion.github.io/nextcloud-playground/?blueprint-url=https://raw.githubusercontent.com/exelearning/nextcloud-exelearning/main/blueprint.json" target="_blank" rel="noopener noreferrer"><img src="https://raw.githubusercontent.com/ateeducacion/nextcloud-playground/refs/heads/main/assets/playground-preview-button.svg" alt="Open in Nextcloud Playground" width="224"></a>

Preview and edit [eXeLearning](https://exelearning.net/) `.elpx` packages
directly inside Nextcloud Files.
Expand Down
2 changes: 2 additions & 0 deletions blueprint.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 18 additions & 7 deletions lib/Controller/EditorController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<base href>` 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
// `<base href>` at runtime) keeps it correct in both a normal install and
// under a scoped path.
$staticConfig = json_encode([
'hideUI' => (object)[
'fileMenu' => true,
'saveButton' => true,
Expand All @@ -244,8 +249,14 @@ public function iframe(): DataDisplayResponse|DataResponse {
],
], JSON_UNESCAPED_SLASHES);

$headInject = '<base href="' . htmlspecialchars($editorBaseHref, ENT_QUOTES) . '">'
. '<script>window.__EXE_EMBEDDING_CONFIG__ = ' . $config . ';</script>';
$configScript = '<script>(function(){'
. 'var base=new URL(".",document.baseURI).href.replace(/\\/+$/,"");'
. 'var origin=window.location.origin;'
. 'window.__EXE_EMBEDDING_CONFIG__=Object.assign(' . $staticConfig . ','
. '{basePath:base,parentOrigin:origin,trustedOrigins:[origin]});'
. '})();</script>';

$headInject = '<base href="' . htmlspecialchars($editorBaseHref, ENT_QUOTES) . '">' . $configScript;
if (preg_match('/<head[^>]*>/i', $html, $m, PREG_OFFSET_CAPTURE)) {
$pos = $m[0][1] + strlen($m[0][0]);
$html = substr($html, 0, $pos) . $headInject . substr($html, $pos);
Expand Down
20 changes: 6 additions & 14 deletions src/editor/editor-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -64,11 +55,12 @@ async function boot(): Promise<void> {
return
}

// Defaults to the route URL; initial state can still override.
const editorIframeUrl = safeLoad<string>(
'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 })

Expand Down
28 changes: 21 additions & 7 deletions src/elpx/iframe-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
25 changes: 25 additions & 0 deletions src/elpx/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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+.-]*:/

Expand Down Expand Up @@ -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.
Expand Down
9 changes: 8 additions & 1 deletion src/view/view-page.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -47,7 +48,13 @@ function boot(): void {

const file = safeLoad<InitialFile | null>('file', null)
const editorAvailable = safeLoad<boolean>('editorAvailable', false)
const editorIframeUrl = safeLoad<string>('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/<scope>/…); 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 })
Expand Down
49 changes: 40 additions & 9 deletions src/viewer/ElpxViewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
<script lang="ts">
import { defineComponent } from 'vue'
import { translate as t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'

import ViewerError from './ViewerError.vue'

Expand All @@ -42,7 +43,8 @@ import { readPackage, ZipReadError } from '../elpx/zip-reader'
import { validatePackage } from '../elpx/package-validator'
import { ViewerSession } from '../elpx/viewer-session'
import { ensureRuntimeWorker, registerSession, unregisterSession, type RuntimeWorker } from '../elpx/service-worker-client'
import { createPackageIframe } from '../elpx/iframe-renderer'
import { createPackageIframe, buildSandboxedIframe } from '../elpx/iframe-renderer'
import { ASSET_PREFIX, buildAssetUrl } from '../elpx/paths'

type State = 'loading' | 'ready' | 'legacy' | 'error'

Expand Down Expand Up @@ -121,20 +123,38 @@ export default defineComponent({
}

this.status = t('exelearning', 'Preparing viewer…')
const indexEntry = validation.shape.indexEntry
const session = ViewerSession.create({
entries,
indexEntry: validation.shape.indexEntry,
indexEntry,
filename: loaded.filename || this.filename || this.basename || 'package.elpx',
})

const worker = await ensureRuntimeWorker()
await registerSession(worker, session)
try {
const worker = await ensureRuntimeWorker()
await registerSession(worker, session)

this.session = session
this.worker = worker
this.state = 'ready'
this.status = ''
this.$nextTick(() => this.attachIframe(worker, session))
this.session = session
this.worker = worker
this.state = 'ready'
this.status = ''
this.$nextTick(() => this.attachIframe(worker, session))
} catch (swError) {
// The runtime Service Worker can't be registered — e.g. when
// Nextcloud is embedded in another origin that already owns a
// controlling Service Worker (the browser fetches the SW
// script straight from the network, bypassing it, so the
// virtual /apps/exelearning/sw.js route 404s). Fall back to the
// server-side AssetController, which streams each entry from the
// stored package. Needs the canonical file id.
if (fileId === undefined || !Number.isFinite(fileId)) {
throw swError
}
console.warn('[exelearning] Service Worker unavailable; using server-side asset fallback.', swError)
this.state = 'ready'
this.status = ''
this.$nextTick(() => this.attachServerIframe(fileId, indexEntry))
}
} catch (error) {
this.handleError(error)
}
Expand All @@ -152,6 +172,17 @@ export default defineComponent({
slot.appendChild(iframe)
this.iframe = iframe
},
attachServerIframe(fileId: number, indexEntry: string): void {
const slot = this.$refs.frameSlot as HTMLElement | undefined
if (!slot) return
slot.innerHTML = ''
const iframe = buildSandboxedIframe(
buildAssetUrl(generateUrl(ASSET_PREFIX), fileId, indexEntry),
this.basename || this.filename || 'package.elpx',
)
slot.appendChild(iframe)
this.iframe = iframe
},
teardown(): void {
this.iframe?.remove()
this.iframe = null
Expand Down
Loading
Loading