From f8792214455e077a8dd792b96ccf1bc0b0cb9178 Mon Sep 17 00:00:00 2001 From: erseco Date: Fri, 26 Jun 2026 08:36:43 +0100 Subject: [PATCH] Always offer the teacher-layer selector via ?exe-teacher=1 --- CHANGELOG.md | 7 ++++++ src/elpx/iframe-renderer.ts | 33 ++++++++++++++++++++++++- tests/js/iframe-renderer.test.ts | 42 ++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 tests/js/iframe-renderer.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 696c293..41a9aaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Added +- Viewer now makes the eXeLearning teacher-layer selector available by loading + the package index with `?exe-teacher=1`. eXeLearning exports hide teacher-only + content by default (see exelearning/exelearning#1972); since the Nextcloud + Viewer is a personal file viewer — the person opening the package is + effectively its author/teacher — the selector is always offered. It stays OFF + by default; the viewer reveals it, and the package's own JS persists the + choice and propagates the param across in-package navigation. - CI matrix (`.github/workflows/ci.yml`) covering NC 31/32/33 × PHP 8.2/8.3/8.4 with rotated databases (sqlite/mysql/pgsql), plus an experimental PHP 8.5 cell. Each cell installs the app into a real diff --git a/src/elpx/iframe-renderer.ts b/src/elpx/iframe-renderer.ts index 3ff39a9..6b18f6b 100644 --- a/src/elpx/iframe-renderer.ts +++ b/src/elpx/iframe-renderer.ts @@ -25,6 +25,31 @@ const IFRAME_ALLOW = [ 'clipboard-write', ].join('; ') +/** + * URL parameter that eXeLearning packages read to expose the in-package + * "teacher layer" selector (see exelearning/exelearning#1972). Without it, + * exported packages hide teacher-only content and offer no way to reveal it. + * + * The Nextcloud Viewer is a personal file viewer — the person opening the + * package is effectively its author/teacher — so we always make the selector + * available. The selector itself stays OFF by default; the viewer activates + * it and the package's own JS persists the choice and propagates the param + * across in-package navigation links. + */ +const TEACHER_MODE_PARAM = 'exe-teacher=1' + +/** + * Appends {@link TEACHER_MODE_PARAM} to the top-level package index URL, + * preserving any existing query string and avoiding a double append. + * @param src Already-resolved index URL the iframe will load. + */ +function withTeacherMode(src: string): string { + if (src.includes(TEACHER_MODE_PARAM)) { + return src + } + return src + (src.includes('?') ? '&' : '?') + TEACHER_MODE_PARAM +} + export interface IframeOptions { runtimeBase: string sessionId: string @@ -46,7 +71,13 @@ export function buildSandboxedIframe(src: string, title: string): HTMLIFrameElem iframe.setAttribute('sandbox', SANDBOX_FLAGS.join(' ')) iframe.setAttribute('allow', IFRAME_ALLOW) iframe.setAttribute('referrerpolicy', 'no-referrer') - iframe.src = src + // `src` is always the package *index* entry — both the Service Worker path + // ({@link createPackageIframe}) and the server-side asset fallback funnel + // the top-level page here, never a subresource. Adding the teacher-mode + // param only to this top-level document is enough: the package's own JS + // propagates it across in-package navigation, and the SW/AssetController + // match requests on the pathname only, so the extra query is harmless. + iframe.src = withTeacherMode(src) iframe.addEventListener('load', () => { try { rewireExternalLinks(iframe) diff --git a/tests/js/iframe-renderer.test.ts b/tests/js/iframe-renderer.test.ts new file mode 100644 index 0000000..b829a0f --- /dev/null +++ b/tests/js/iframe-renderer.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { + buildSandboxedIframe, + createPackageIframe, +} from '../../src/elpx/iframe-renderer' +import { RUNTIME_PREFIX } from '../../src/elpx/paths' + +describe('buildSandboxedIframe', () => { + it('appends the eXeLearning teacher-mode param to the index src', () => { + const iframe = buildSandboxedIframe('/apps/exelearning/asset/42/index.html', 'pkg') + expect(iframe.src).toContain('exe-teacher=1') + expect(iframe.src).toContain('/apps/exelearning/asset/42/index.html?exe-teacher=1') + }) + + it('uses & when the index src already carries a query string', () => { + const iframe = buildSandboxedIframe('/apps/exelearning/asset/42/index.html?foo=bar', 'pkg') + expect(iframe.src).toContain('?foo=bar&exe-teacher=1') + }) + + it('does not double-append when the param is already present', () => { + const iframe = buildSandboxedIframe('/apps/exelearning/asset/42/index.html?exe-teacher=1', 'pkg') + expect(iframe.src.match(/exe-teacher=1/g)).toHaveLength(1) + }) + + it('sets the sandbox flags and accessible title', () => { + const iframe = buildSandboxedIframe('/apps/exelearning/asset/42/index.html', 'My package') + expect(iframe.getAttribute('sandbox')).toContain('allow-scripts') + expect(iframe.title).toBe('My package') + }) +}) + +describe('createPackageIframe', () => { + it('builds a runtime index src that exposes the teacher-mode selector', () => { + const iframe = createPackageIframe({ + runtimeBase: RUNTIME_PREFIX, + sessionId: 'session-1', + indexEntry: 'index.html', + title: 'pkg', + }) + expect(iframe.src).toContain(`${RUNTIME_PREFIX}/session-1/index.html?exe-teacher=1`) + }) +})