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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 32 additions & 1 deletion src/elpx/iframe-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
42 changes: 42 additions & 0 deletions tests/js/iframe-renderer.test.ts
Original file line number Diff line number Diff line change
@@ -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`)
})
})