Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
77bf3d3
feat(preview): host-served opaque HTTP preview serving contract (refe…
erseco Jul 6, 2026
0c411a8
fix(preview): add trailing newline to PreviewController.php
erseco Jul 6, 2026
2a5beaa
feat(viewer): add IframeSandbox service (secure tokens + published CSP)
erseco Jul 6, 2026
b664e6a
feat(viewer): add fileId-bound capability token service
erseco Jul 6, 2026
60ca7af
feat(viewer): opaque HTTP content-serving route (capability token + C…
erseco Jul 6, 2026
e48c253
feat(viewer): mint content capability token at view-open
erseco Jul 6, 2026
8eda8a7
feat(viewer): opaque iframe (drop allow-same-origin) + content-URL bu…
erseco Jul 6, 2026
53ec20a
feat(viewer): external-media relay + media host in the opaque viewer
erseco Jul 6, 2026
b9ed9c2
feat(viewer): strict-by-default embed relay mode
erseco Jul 6, 2026
e129bdc
docs(viewer): secure opaque-origin published-content viewer
erseco Jul 6, 2026
a03206d
style(viewer): align docblocks (nextcloud/coding-standard php-cs-fixer)
erseco Jul 6, 2026
8587e6c
fix(viewer): bind capability token to the user id (fix opaque 404)
erseco Jul 6, 2026
5dbb9c3
feat(playground): flip the dev-only legacy hatch via app-config for t…
erseco Jul 7, 2026
3cfa994
Merge branch 'main' into feature/secure-iframe-sandbox
erseco Jul 7, 2026
99f2866
feat(preview): host-served opaque preview serving contract v2
erseco Jul 11, 2026
51fad0b
test(preview): OCP-free unit tests + shared conformance vector replay
erseco Jul 11, 2026
db50d04
docs(preview): rewrite serving-contract mirror to v2
erseco Jul 11, 2026
c8443a6
fix(embed): mirror core relay drift-check + shim reflow observers
erseco Jul 11, 2026
8200b3d
fix(preview): prune superseded revisions to bound preview-session disk
erseco Jul 11, 2026
26fa25d
fix(preview): reject failed asset writes instead of marking them alre…
erseco Jul 11, 2026
1805b8a
fix(embed): mirror core relay dispose() + idempotent init
erseco Jul 11, 2026
9c74f2e
Merge remote-tracking branch 'origin/main' into feature/secure-iframe…
erseco Jul 11, 2026
2f1cf46
fix(preview): bootstrap the embedded editor with previewHttp config
erseco Jul 11, 2026
33dc623
fix(preview): correct serving Range and bare-root policy
erseco Jul 11, 2026
b61305d
fix(preview): reclaim ghost sessions with a lost .accessed marker
erseco Jul 11, 2026
436d9d7
fix(preview): reject revisions with a dropped or failed multipart part
erseco Jul 11, 2026
38f794c
docs(preview): normalize serving-contract vocab; correct viewer descr…
erseco Jul 11, 2026
5b0c92e
test(preview): API-level end-to-end against a real Nextcloud
erseco Jul 11, 2026
d7a2a67
ci(preview): probe /status.php for readiness in the preview E2E job
erseco Jul 11, 2026
f4549fd
fix(preview): align Range classification with the canonical contract
erseco Jul 11, 2026
40fff92
docs(preview): document audited CSRF-token durability
erseco Jul 11, 2026
1195851
ci(preview): harden preview E2E auth against set -e and add diagnostics
erseco Jul 11, 2026
472ffe6
ci(preview): get post-login requesttoken from /csrftoken, not app HTML
erseco Jul 11, 2026
3041e30
ci(preview): force http protocol + verify session auth in preview E2E
erseco Jul 11, 2026
26eac92
ci(preview): complete plain-http config + raw login diagnostics
erseco Jul 11, 2026
56eb4fa
ci(preview): drive the preview E2E over cookieless Basic auth
erseco Jul 11, 2026
b3faa34
docs(preview): document core artifact activation testing
erseco Jul 12, 2026
7f0b0d3
Merge branch 'main' into feature/secure-iframe-sandbox
erseco Jul 12, 2026
65e0b65
Merge branch 'main' into feature/secure-iframe-sandbox
erseco Jul 12, 2026
a92b3d1
fix(viewer): stream ZIP fallback in playground
erseco Jul 12, 2026
fa1b41d
fix(viewer): retain auth in asset fallback
erseco Jul 12, 2026
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
4 changes: 4 additions & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ module.exports = {
'js/',
'node_modules/',
'vendor/',
// Centrally-maintained, byte-synced eXe-core embed/media bridge mirrors
// (see scripts/check-embed-sync.mjs). They keep upstream's code style and
// module wrapper, so our lint rules do not apply to them.
'src/embed/exe_*.js',
],
rules: {
// `void promise` is the canonical TypeScript way of marking a
Expand Down
91 changes: 91 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,94 @@ jobs:
# that the file actually got staged into the app tree.
test -f apps/exelearning/src/sw/exelearning-sw.js \
|| test -f custom_apps/exelearning/src/sw/exelearning-sw.js

preview-e2e:
name: Editor-preview API E2E (real Nextcloud)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7

- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: pdo_sqlite, gd, zip, intl, mbstring, ctype, curl, dom, fileinfo, simplexml, xml, xmlreader, xmlwriter
coverage: none

- name: Install Composer dependencies
run: composer install --prefer-dist --no-progress

- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: npm

- name: Install JS dependencies
run: npm ci

- name: Build frontend
run: npm run build

- name: Set up Nextcloud server
uses: SMillerDev/nextcloud-actions/setup-nextcloud@main
with:
version: stable33
database-type: sqlite
cron: false

- name: Install eXeLearning app into the running Nextcloud
uses: SMillerDev/nextcloud-actions/setup-nextcloud-app@main
with:
app: exelearning
check-code: false

# Serve the installed Nextcloud over HTTP (the built-in PHP server routes
# everything through the front controller, `/index.php/...`), enable the
# app, create the owner + a second user, then drive the real management
# and serving routes end to end. Cookie/session auth is used on purpose so
# the CSRF middleware is genuinely exercised.
- name: Preview API end-to-end against the running Nextcloud
working-directory: ../server
env:
WORKSPACE: ${{ github.workspace }}
run: |
set -euo pipefail
php occ app:enable exelearning
php occ config:system:set trusted_domains 1 --value=127.0.0.1
# Treat the origin as plain HTTP end-to-end so the login session cookie
# and the SameSite cookies are NOT marked Secure — curl over http never
# sends a Secure cookie back, which silently breaks the login POST's
# own CSRF/strict-cookie check and leaves every request unauthenticated.
# Use a webroot WITHOUT /index.php so cookies get Path=/ (routing still
# goes through the /index.php front controller under php -S).
php occ config:system:set overwrite.cli.url --value="http://127.0.0.1:8080"
php occ config:system:set overwriteprotocol --value=http
php occ config:system:delete forcessl 2>/dev/null || true
php occ config:system:delete overwritehost 2>/dev/null || true

export OC_PASS='Preview-e2e-owner-1!'
php occ user:add --password-from-env previewe2e1
export OC_PASS='Preview-e2e-other-2!'
php occ user:add --password-from-env previewe2e2

# The built-in server's document root is the Nextcloud tree, so the
# top-level status.php is served directly (routed app URLs go through
# /index.php/... instead).
php -S 127.0.0.1:8080 >"${RUNNER_TEMP}/nc-server.log" 2>&1 &
echo $! > "${RUNNER_TEMP}/nc-server.pid"
ready=
for _ in $(seq 1 30); do
if curl -sf "http://127.0.0.1:8080/status.php" | grep -q '"installed":true'; then ready=1; break; fi
sleep 1
done
[ "$ready" = "1" ] || { echo "Nextcloud did not come up"; curl -s "http://127.0.0.1:8080/status.php" || true; cat "${RUNNER_TEMP}/nc-server.log"; exit 1; }

EXE_E2E_BASE="http://127.0.0.1:8080/index.php" \
EXE_E2E_USER1=previewe2e1 EXE_E2E_PASS1='Preview-e2e-owner-1!' \
EXE_E2E_USER2=previewe2e2 EXE_E2E_PASS2='Preview-e2e-other-2!' \
bash "${WORKSPACE}/tests/e2e/preview-api-e2e.sh" \
|| { echo '--- nextcloud.log (tail) ---'; tail -n 80 data/nextcloud.log 2>/dev/null || true; exit 1; }

kill "$(cat "${RUNNER_TEMP}/nc-server.pid")" 2>/dev/null || true
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- 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
Nextcloud server and verifies the Service Worker route responds.
Nextcloud server and verifies it enables cleanly, and an API-level
end-to-end job exercises the editor-preview management and serving routes
against a live Nextcloud (CSRF enforcement, ownership, revision publish,
sandbox CSP, session deletion).
- `make ci-matrix` reproduces the matrix locally with Docker.
- README "Compatibility" table backed by the CI matrix and a CI badge.
- Initial Nextcloud app scaffold (`appinfo/info.xml`, routes, bootstrap).
Expand Down
29 changes: 21 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,24 @@ playground link (posted as a PR comment) built from that branch — see
When a user clicks a `.elpx` file in Nextcloud Files:

1. A Nextcloud Viewer modal opens.
2. The package is downloaded for the current user.
3. The browser extracts the ZIP archive in memory.
4. The internal `index.html` renders inside a **sandboxed iframe**.
5. Relative assets (`html/`, `content/`, `libs/`, `theme/`, `idevices/`,
images, CSS, JS, audio, video, …) are served by a scoped Service Worker
from the in-memory extraction — no second request to the server.
2. The package is downloaded and validated for the current user.
3. The internal `index.html` and its relative assets (`html/`, `content/`,
`libs/`, `theme/`, `idevices/`, images, CSS, JS, audio, video, …) render
inside an **opaque-origin sandboxed iframe** — a sandbox **without**
`allow-same-origin`, so the untrusted author scripts cannot reach the
Nextcloud session. The bytes are served over real HTTP from a cookieless,
capability-token route (`/content/{token}/{path}`), because a Service Worker
cannot back an opaque origin. External media (YouTube/Vimeo/PDF/…) is relayed
to the trusted parent page. See [docs/secure-iframe-viewer.md](docs/secure-iframe-viewer.md).

The bundled static editor ("Edit with eXeLearning") renders its live **editor
preview** the same way — an opaque-origin iframe served over the HTTP preview
transport (serving contract v2), never the Service Worker. See
[docs/preview-serving-contract.md](docs/preview-serving-contract.md).

> The legacy Service Worker viewer remains only as an explicit, dev-only opt-in
> (a trusted-content compatibility mode, **not** a security boundary); the opaque
> HTTP path is the default.

Optional features:

Expand Down Expand Up @@ -84,8 +96,9 @@ maintenance ends mid-2026; NC 32 and 33 are the reference targets.
| npm | 10 or 11 |
| Bun (optional) | latest stable, only for `build-editor` |

Browsers must support Service Workers in the Nextcloud origin. The viewer
will refuse to start otherwise.
The default opaque-origin viewer and the HTTP editor preview are served over
plain HTTP and do **not** require Service Workers. Only the legacy, opt-in
Service Worker viewer needs Service Worker support in the Nextcloud origin.

## Quick start with Docker

Expand Down
15 changes: 13 additions & 2 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,18 @@
<summary>Preview and edit eXeLearning .elpx packages in Nextcloud</summary>
<description><![CDATA[
Provides a Nextcloud Files viewer for eXeLearning `.elpx` packages by
rendering the internal `index.html` inside a sandboxed iframe.
rendering the internal `index.html` inside an opaque-origin sandboxed iframe.

The app registers a Nextcloud Viewer handler so `.elpx` files open in a modal
with the package fully navigable: HTML pages, CSS, JavaScript, images, audio
and video resolve from the package contents through a scoped Service Worker.
and video resolve from the package contents. Because the rendered author
content is untrusted, it is served from a cookieless, capability-token HTTP
route into a sandbox without `allow-same-origin` — a browser-enforced opaque
origin isolated from the Nextcloud session — rather than same-origin. External
media (YouTube, Vimeo, PDF, …) is relayed to the trusted parent page. The
bundled static editor renders its live preview the same way, over the HTTP
editor-preview transport (serving contract v2). A legacy Service Worker viewer
survives only as an explicit, dev-only opt-in.

Optional features:

Expand All @@ -37,4 +44,8 @@ This app is Nextcloud-native and does not depend on Google Drive.
<php min-version="8.2" max-version="8.5"/>
<nextcloud min-version="31" max-version="33"/>
</dependencies>

<background-jobs>
<job>OCA\ExeLearning\BackgroundJob\PreviewCleanupJob</job>
</background-jobs>
</info>
48 changes: 48 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,54 @@
'verb' => 'GET',
'requirements' => ['path' => '.+'],
],
[
'name' => 'content#serve',
'url' => '/content/{token}/{path}',
'verb' => 'GET',
'requirements' => ['token' => '[A-Za-z0-9._~-]+', 'path' => '.+'],
'defaults' => ['path' => 'index.html'],
],
// Editor-preview serving contract v2. The serving route is an authless,
// cookieless capability URL gated solely on the unguessable previewId
// UUID; the management routes are authenticated + owner-scoped (CSRF on).
[
'name' => 'preview#serve',
'url' => '/preview/{previewId}/{path}',
'verb' => 'GET',
'requirements' => [
'previewId' => '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}',
'path' => '.+',
],
],
[
'name' => 'preview#serveRoot',
'url' => '/preview/{previewId}',
'verb' => 'GET',
'requirements' => ['previewId' => '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'],
],
[
'name' => 'previewSession#create',
'url' => '/api/preview-session',
'verb' => 'POST',
],
[
'name' => 'previewSession#uploadAssets',
'url' => '/api/preview-session/{previewId}/assets',
'verb' => 'POST',
'requirements' => ['previewId' => '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'],
],
[
'name' => 'previewSession#publishRevision',
'url' => '/api/preview-session/{previewId}/revisions',
'verb' => 'POST',
'requirements' => ['previewId' => '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'],
],
[
'name' => 'previewSession#delete',
'url' => '/api/preview-session/{previewId}',
'verb' => 'DELETE',
'requirements' => ['previewId' => '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'],
],
[
'name' => 'thumbnail#byFileId',
'url' => '/thumbnail/by-file-id/{fileId}',
Expand Down
3 changes: 2 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"!**/coverage/**",
"!**/js/**",
"!**/exelearning/**",
"!**/*.vue"
"!**/*.vue",
"!**/src/embed/exe_*.js"
]
},
"formatter": {
Expand Down
7 changes: 7 additions & 0 deletions blueprint.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@
"url": "https://github-proxy.exelearning.dev/?repo=exelearning/exelearning&release=v4.0.2&asset=exelearning-static-v4.0.2.zip",
"destination": "apps/exelearning/js/editor"
},
{
"step": "setConfig",
"comment": "DEV-ONLY Playground demo hatch — NEVER production. The viewer defaults to the secure opaque-origin /content/{token} route, which a Service Worker CANNOT serve (it 404s in the php-wasm Playground). This app-config value is read by the envReader wired in lib/AppInfo/Application.php and flips lib/Service/IframeSandbox::resolveMode() to 'legacy', so ViewController hands the viewer the same-origin Service-Worker/AssetController path that DOES render in the browser. Mirrors the wp-exelearning mu-plugin / mod_exelearning phpConstants demo hatch. Maps to `occ config:app:set exelearning unsafe_legacy_iframe --value 1`; re-introduces allow-same-origin and drops opaque-origin isolation, so it must never be set on a real deployment.",
"app": "exelearning",
"key": "unsafe_legacy_iframe",
"value": "1"
},
{
"step": "writeFile",
"path": "config/mimetypemapping.json",
Expand Down
Loading