diff --git a/.distignore b/.distignore
index fdc801e..b5e982c 100644
--- a/.distignore
+++ b/.distignore
@@ -13,11 +13,15 @@
# --- Frontend sources -------------------------------------------------
# All TypeScript / Vue under src/ is compiled into js/ at build time
# and not needed at runtime, EXCEPT src/sw/ which `SwController.php`
-# reads to serve /apps/exelearning/sw.js. Whitelist that subtree
-# before excluding the rest of src/.
+# reads to serve /apps/exelearning/sw.js, and
+# src/embed/exe_external_media/ which `ContentController.php` inlines into every
+# served package. Whitelist those subtrees before excluding the rest of src/.
+ src/
+ src/sw/
+ src/sw/**
++ src/embed/
++ src/embed/exe_external_media/
++ src/embed/exe_external_media/**
- src/**
# --- Version control & developer metadata --------------------------------
diff --git a/.eslintrc.cjs b/.eslintrc.cjs
index c41d06b..5305279 100644
--- a/.eslintrc.cjs
+++ b/.eslintrc.cjs
@@ -18,6 +18,11 @@ module.exports = {
'js/',
'node_modules/',
'vendor/',
+ // The vendored eXe-core external-media bundle: built artifacts owned by
+ // upstream (eXe ADR-0021), verified byte for byte against the manifest core
+ // published. They are minified output, not source, so our lint rules do not
+ // apply — and a local "fix" here would be overwritten on the next re-vendor.
+ 'src/embed/exe_external_media/',
],
rules: {
// `void promise` is the canonical TypeScript way of marking a
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a5b69e0..c90de45 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -41,6 +41,19 @@ jobs:
- name: Install dependencies
run: npm ci
+ - name: Verify the vendored external-media artifact
+ # eXeLearning core is canonical (eXe ADR-0021): this repo holds the BYTES and
+ # verifies them against the manifest core published, rather than a copy of the
+ # logic that could drift. --build-hash makes it a PROVENANCE check and not merely
+ # an integrity one: without it a locally rebuilt-and-resigned copy would pass.
+ #
+ # The expected hash is PINNED HERE, deliberately, and not read from the vendored
+ # manifest. A hash taken from the copy under test cannot say anything about that
+ # copy: file, digest and buildHash can all be rewritten together, and a check that
+ # trusted them would pass a consistent forgery. Bump it in the same commit that
+ # re-vendors the artifact.
+ run: node src/embed/exe_external_media/verify.mjs src/embed/exe_external_media --build-hash "43c776daa551fd770b6dade520629362e5a8ca8745e4245c359447c4689b34d6"
+
# Biome lints .ts/.js under src/. Vue single-file components stay
# with ESLint (better Vue support); see biome.json for scope.
- name: Biome lint
@@ -177,3 +190,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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 41a9aaa..08e9f6e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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).
diff --git a/Makefile b/Makefile
index 907f9c5..1f8a1d5 100644
--- a/Makefile
+++ b/Makefile
@@ -346,7 +346,12 @@ appstore: package
# sidesteps that.
#
# Override host port with `make up DOCKER_PORT=9000`.
-APP_RUNTIME_DIRS := appinfo lib js templates img src/sw
+# src/embed carries the vendored external-media child bundle, which
+# ContentController inlines into every served package AT RUNTIME — like src/sw,
+# it is shipped source, not build input. Leaving it out made shimSource() return
+# null inside the container: no shim injected, no embed ever promoted, and no
+# error anywhere to say so.
+APP_RUNTIME_DIRS := appinfo lib js templates img src/sw src/embed
# Verify both that the docker CLI exists and that its daemon is
# reachable. `docker version` talks to the daemon, so it fails with a
diff --git a/README.md b/README.md
index 9e5c180..13d3f5d 100644
--- a/README.md
+++ b/README.md
@@ -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:
@@ -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
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 791c955..d5c65f8 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -6,11 +6,18 @@
Preview and edit eXeLearning .elpx packages in Nextcloud
+
+
+ OCA\ExeLearning\BackgroundJob\PreviewCleanupJob
+
diff --git a/appinfo/routes.php b/appinfo/routes.php
index ded2661..00032f7 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -21,6 +21,42 @@
'verb' => 'GET',
'requirements' => ['path' => '.+'],
],
+ [
+ 'name' => 'content#serve',
+ 'url' => '/content/{token}/{path}',
+ 'verb' => 'GET',
+ 'requirements' => ['token' => '[A-Za-z0-9._~-]+', 'path' => '.+'],
+ 'defaults' => ['path' => 'index.html'],
+ ],
+ // Opaque editor preview. 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#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}',
diff --git a/biome.json b/biome.json
index 48e4ede..09f3bef 100644
--- a/biome.json
+++ b/biome.json
@@ -16,7 +16,8 @@
"!**/coverage/**",
"!**/js/**",
"!**/exelearning/**",
- "!**/*.vue"
+ "!**/*.vue",
+ "!**/src/embed/exe_external_media/**"
]
},
"formatter": {
diff --git a/blueprint.json b/blueprint.json
index c7c8346..7798b25 100644
--- a/blueprint.json
+++ b/blueprint.json
@@ -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",
diff --git a/docs/preview-serving-contract.md b/docs/preview-serving-contract.md
new file mode 100644
index 0000000..3502d8b
--- /dev/null
+++ b/docs/preview-serving-contract.md
@@ -0,0 +1,162 @@
+# Opaque editor preview — Nextcloud adapter
+
+The embedded editor renders its preview **filtered** by default: sanitised, with
+no author JavaScript running. When the author opts in to running their own code,
+the editor needs somewhere to put the real project bytes that is **not** the
+Nextcloud page — a browser-enforced **opaque origin** the content cannot reach
+out of.
+
+This app is that somewhere. The editor POSTs the whole project as one ZIP and
+gets back an unguessable capability id; the app serves that tree from an authless
+route under a sandbox CSP. There is no authored-content `srcdoc` transport and no
+Service Worker fallback here: missing or invalid configuration **fails closed**
+and the filtered preview stays.
+
+The sandbox CSP string must stay **byte-identical** to eXe core's
+`previewCspHeader()`; core is authoritative.
+
+## Why not the Service Worker
+
+For *published* `.elpx` content this app serves package bytes same-origin (the
+Service Worker `src/sw/exelearning-sw.js` and the authenticated
+`AssetController::fetch`) — fine for content the owner has committed.
+
+The **editor preview** is different: it renders *untrusted, unsaved* author HTML
+and SVG that can contain arbitrary scripts. Rendering that same-origin — or via a
+Service Worker in our scope — would let it read the Nextcloud session, call our
+APIs and pivot. A Service Worker **cannot** back an opaque origin (its
+subresources bypass the SW), so the preview is served from an authless,
+cookieless route and never through the Service Worker.
+
+## The two endpoints
+
+| | Request | Result |
+|---|---|---|
+| Management | `POST {basePath}/api/preview-session` | multipart `snapshot=`, optional `previewId` → `{previewId}` |
+| Management | `DELETE {basePath}/api/preview-session/{previewId}` | drops the snapshot |
+| Serving | `GET {basePath}/preview/{previewId}/{path}` | the snapshot, authless |
+
+Management is the only authenticated surface: a logged-in `IUserSession` user
+plus Nextcloud's ordinary CSRF check (the actions are deliberately **not**
+`#[NoCSRFRequired]`, mirroring `editor#save`), and the store scopes every
+snapshot to its owner (403/404).
+
+Serving carries no authentication at all. The unguessable id plus the idle TTL is
+the whole credential, which is what makes the origin opaque — an iframe pointed
+at this URL carries no Nextcloud session, so author code inside it has nothing to
+steal.
+
+## Why one whole snapshot
+
+An earlier revision implemented a layered protocol (contract v2): immutable asset
+keys uploaded once, incremental document revisions, and a manifest of fixed
+installation resources resolved out of the editor distribution — all to avoid
+re-uploading unchanged bytes. The editor no longer speaks it; it was handed a
+contract nothing read while the one it does read (`previewSnapshot`) was
+withheld, which left the opaque preview unreachable here. One ZIP per refresh
+replaced the store, the fixed-resource layer and the four-operation management
+API.
+
+## Storage
+
+ {datadirectory}/exelearning/preview-snapshots/{previewId}/
+ meta.json ownerUserId, createdAt, bytes
+ .accessed empty marker; its mtime is the idle-TTL / LRU clock
+ content/ the extracted snapshot
+
+Outside the web root, so no direct web-server path can bypass the serving route
+and its sandbox CSP. The root comes from `datadirectory` rather than `IAppData`
+because the store needs a POSIX-local directory for atomic renames, and
+`IAppData` may be object storage. Content sits in its own subdirectory so no
+author path can collide with the store's own files — there are no reserved names
+to police. A write is staged beside the live tree and swapped in, so a reader
+sees the previous snapshot or the new one, never a half-written one.
+
+## What an archive must survive before extraction
+
+`SnapshotArchive` vets every entry **before** anything is written, then extracts
+under a byte budget:
+
+- Unsafe entries (absolute paths, backslashes, `.`/`..` segments, NUL bytes) and
+ symbolic links reject the **whole** archive in the first pass.
+- The zip-bomb cap is enforced on the **real decompressed bytes** as they stream
+ out, not on the sizes declared in the central directory — those are supplied by
+ whoever built the archive. The declared total is only an early reject.
+- An `index.html` must be present, or it is not a preview.
+
+## Bounds
+
+Nextcloud is the one adapter where these matter beyond a single author, because
+one shared instance hosts many users against one filesystem:
+
+| Bound | Default |
+|---|---|
+| Idle TTL | 30 min |
+| Snapshots per user | 4 (LRU-evicted) |
+| Files per snapshot | 5000 |
+| Bytes per snapshot | 200 MiB |
+| Global | 2 GiB (LRU-evicted, then 507) |
+
+Serving a snapshot pushes its idle clock back, so a preview in use never expires
+under the author. Every publish sweeps, and `PreviewCleanupJob` sweeps every
+10 minutes, so the store never depends on cron alone to bound its size.
+
+## Response headers
+
+Every response, 404s included, carries `X-Content-Type-Options: nosniff`,
+`Referrer-Policy: no-referrer`, the preview `Permissions-Policy` and
+`Access-Control-Allow-Origin: *` — safe here precisely because the route is
+authless and cookieless, and never to be paired with credentials. There is
+deliberately no `X-Frame-Options`: framing is governed by the CSP
+`frame-ancestors` directive.
+
+Every **scriptable** type — `text/html`, `image/svg+xml`, XML, XHTML — also gets
+the sandbox CSP, so a capability URL stays opaque even when opened directly. Not
+just HTML: an author-supplied SVG runs its inline `';
// The resilience shim must be installed before any editor script
@@ -292,6 +298,74 @@ public function iframe(): DataDisplayResponse|DataResponse {
return $response;
}
+ /**
+ * The `previewSnapshot` block handed to the embedded editor so it can publish
+ * opaque previews: `{ managementUrl, servingBaseUrl, deleteUrlTemplate,
+ * managementHeaders }`.
+ *
+ * - Both URLs are generated server-side through the router so they carry the
+ * correct webroot and front-controller prefix under a sub-path install,
+ * mirroring the client's `@nextcloud/router` `generateUrl`. The serving
+ * routes are parameterised, so the base is derived by generating the bare
+ * capability-root URL for a placeholder id and stripping that id segment.
+ * - `managementHeaders.requesttoken` is the current Nextcloud CSRF token —
+ * the same encrypted value the standard template layer exposes as
+ * `data-requesttoken`. It is required because the management routes keep
+ * CSRF ON (they are NOT `#[NoCSRFRequired]`); the editor replays it on every
+ * management request.
+ *
+ * Token lifetime (audited — the injected-token approach is durable, no
+ * refresh path needed now): a Nextcloud CSRF token is bound to a per-session
+ * secret. `getEncryptedValue()` returns a per-call randomized encoding, but
+ * every value minted from the same session validates against that one secret
+ * (`isTokenValid()` decrypts and compares), so the injected value stays valid
+ * for the whole editing session — hours — not just one request. The secret is
+ * regenerated only when the session id itself is regenerated (login / logout /
+ * re-auth), never on ordinary navigation; and whenever that happens the parent
+ * Nextcloud page hosting this iframe is itself invalidated and reloads, which
+ * re-runs iframe() and injects a fresh token. The parent page also keeps the
+ * session (and `OC.requestToken`) alive with its keepalive heartbeat. The only
+ * residual staleness — the parent's token rotating without a reload — cannot
+ * happen in practice, so a postMessage CONFIGURE refresh into the iframe is a
+ * future nicety, not a correctness requirement.
+ *
+ * @return array{managementUrl:string,servingBaseUrl:string,deleteUrlTemplate:string,managementHeaders:object}
+ */
+ private function previewSnapshotConfig(): array {
+ $sampleId = '00000000-0000-4000-8000-000000000000';
+
+ // The serving routes take a `previewId` (and `path`); generate the bare
+ // capability-root URL for a placeholder id, then strip the trailing
+ // `/{id}` so the client can append `/{previewId}/index.html` itself.
+ $sampleUrl = $this->urlGenerator->linkToRoute(
+ Application::APP_ID . '.preview.serveRoot',
+ ['previewId' => $sampleId],
+ );
+ $servingBaseUrl = str_ends_with($sampleUrl, '/' . $sampleId)
+ ? substr($sampleUrl, 0, -(strlen($sampleId) + 1))
+ : $sampleUrl;
+
+ // The delete route is templated rather than derived by concatenation, so
+ // the client never has to know how this app spells a capability URL.
+ $deleteUrlTemplate = str_replace(
+ $sampleId,
+ '{previewId}',
+ $this->urlGenerator->linkToRoute(
+ Application::APP_ID . '.previewSession.delete',
+ ['previewId' => $sampleId],
+ ),
+ );
+
+ return [
+ 'managementUrl' => $this->urlGenerator->linkToRoute(Application::APP_ID . '.previewSession.create'),
+ 'servingBaseUrl' => $servingBaseUrl,
+ 'deleteUrlTemplate' => $deleteUrlTemplate,
+ 'managementHeaders' => (object)[
+ 'requesttoken' => $this->csrfTokenManager->getToken()->getEncryptedValue(),
+ ],
+ ];
+ }
+
/**
* Resilience shim injected into the editor before any editor
* script runs. The static eXeLearning editor's ResourceFetcher rejects
diff --git a/lib/Controller/PreviewController.php b/lib/Controller/PreviewController.php
new file mode 100644
index 0000000..bb43ff4
--- /dev/null
+++ b/lib/Controller/PreviewController.php
@@ -0,0 +1,89 @@
+server->serve(
+ $previewId,
+ $path,
+ $this->headerOrNull('If-None-Match'),
+ $this->headerOrNull('Range'),
+ );
+ return $this->toDataDisplay($response);
+ }
+
+ /**
+ * GET /apps/exelearning/preview/{previewId}
+ *
+ * The bare capability root 302-redirects to `{previewId}/index.html` rather
+ * than serving index.html bytes inline — otherwise the document's relative
+ * subresource references resolve against `preview/` (dropping the id segment)
+ * and every asset 404s. See {@see PreviewServer::serveRoot()}.
+ */
+ #[PublicPage]
+ #[NoCSRFRequired]
+ public function serveRoot(string $previewId): DataDisplayResponse {
+ return $this->toDataDisplay($this->server->serveRoot($previewId));
+ }
+
+ /** Map the framework-agnostic response onto a DataDisplayResponse. */
+ private function toDataDisplay(PreviewResponse $response): DataDisplayResponse {
+ $headers = $response->headers;
+ $contentType = $headers['Content-Type'] ?? 'application/octet-stream';
+ unset($headers['Content-Type']);
+ $result = new DataDisplayResponse($response->body, $response->status, ['Content-Type' => $contentType]);
+ foreach ($headers as $name => $value) {
+ $result->addHeader($name, $value);
+ }
+ return $result;
+ }
+
+ /** A request header value, or null when it is absent/empty. */
+ private function headerOrNull(string $name): ?string {
+ $value = (string)$this->request->getHeader($name);
+ return $value === '' ? null : $value;
+ }
+}
diff --git a/lib/Controller/PreviewSessionController.php b/lib/Controller/PreviewSessionController.php
new file mode 100644
index 0000000..c275ff9
--- /dev/null
+++ b/lib/Controller/PreviewSessionController.php
@@ -0,0 +1,109 @@
+,
+ * previewId? -> { previewId }
+ * DELETE {basePath}/api/preview-session/{previewId}
+ *
+ * This replaces the four-operation protocol v2 (create / assets / revisions /
+ * delete) that the current editor build no longer speaks.
+ */
+class PreviewSessionController extends Controller {
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ private readonly IUserSession $userSession,
+ private readonly PreviewSnapshotStore $store,
+ ) {
+ parent::__construct($appName, $request);
+ }
+
+ /**
+ * POST {basePath}/api/preview-session — publish a whole-project snapshot.
+ *
+ * `previewId` is absent on the first refresh (mint a capability) and present
+ * afterwards (replace in place). The store refuses an id that is unknown or
+ * owned by somebody else, so it cannot be used to claim another author's
+ * capability.
+ */
+ #[NoAdminRequired]
+ public function create(): DataResponse {
+ $userId = $this->currentUserId();
+ if ($userId === null) {
+ return $this->unauthenticated();
+ }
+ $upload = $this->request->getUploadedFile('snapshot');
+ if (!is_array($upload)
+ || (int)($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK
+ || !is_string($upload['tmp_name'] ?? null)
+ || $upload['tmp_name'] === ''
+ || !is_uploaded_file($upload['tmp_name'])) {
+ return new DataResponse(['error' => 'Missing snapshot upload'], Http::STATUS_BAD_REQUEST);
+ }
+
+ $previewId = $this->request->getParam('previewId');
+ $previewId = is_string($previewId) && $previewId !== '' ? $previewId : null;
+
+ $result = $this->store->replace($userId, $upload['tmp_name'], $previewId);
+ if (isset($result['error'])) {
+ return new DataResponse(['error' => $result['error']], (int)$result['status']);
+ }
+ // No previewUrl: the client derives it from servingBaseUrl +
+ // /{previewId}/index.html, which keeps one source of truth for how a
+ // capability URL is shaped.
+ return new DataResponse(['previewId' => $result['previewId']], Http::STATUS_OK);
+ }
+
+ /**
+ * DELETE {basePath}/api/preview-session/{previewId}
+ *
+ * Owner scoping comes from the same store verdict the publish path uses, so
+ * the two verbs cannot drift: a malformed id is a 400, an unknown capability
+ * a 404 and somebody else's a 403.
+ */
+ #[NoAdminRequired]
+ public function delete(string $previewId): DataResponse {
+ $userId = $this->currentUserId();
+ if ($userId === null) {
+ return $this->unauthenticated();
+ }
+ $refused = $this->store->deleteOwned($previewId, $userId);
+ if ($refused !== null) {
+ return new DataResponse(['error' => $refused['error']], (int)$refused['status']);
+ }
+ return new DataResponse([], Http::STATUS_OK);
+ }
+
+ private function currentUserId(): ?string {
+ $user = $this->userSession->getUser();
+ return $user === null ? null : $user->getUID();
+ }
+
+ private function unauthenticated(): DataResponse {
+ return new DataResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
+}
diff --git a/lib/Controller/ViewController.php b/lib/Controller/ViewController.php
index 87ac8a1..ec2f2f1 100644
--- a/lib/Controller/ViewController.php
+++ b/lib/Controller/ViewController.php
@@ -5,7 +5,9 @@
namespace OCA\ExeLearning\Controller;
use OCA\ExeLearning\AppInfo\Application;
+use OCA\ExeLearning\Service\ContentTokenService;
use OCA\ExeLearning\Service\ElpxPackageService;
+use OCA\ExeLearning\Service\IframeSandbox;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
@@ -39,6 +41,8 @@ public function __construct(
private readonly ElpxPackageService $packageService,
private readonly IInitialState $initialState,
private readonly IURLGenerator $urlGenerator,
+ private readonly ContentTokenService $contentTokens,
+ private readonly IframeSandbox $sandbox,
) {
parent::__construct($appName, $request);
}
@@ -66,6 +70,7 @@ public function index(?int $fileId = null, ?string $path = null, ?string $mode =
}
}
+ $secureIframe = $this->sandbox->resolveMode() === IframeSandbox::MODE_SECURE;
if ($file !== null) {
$this->initialState->provideInitialState('file', [
'id' => $file->getId(),
@@ -75,7 +80,25 @@ public function index(?int $fileId = null, ?string $path = null, ?string $mode =
'etag' => $file->getEtag(),
'writable' => $file->isUpdateable(),
]);
+ // In secure mode the package is served into an opaque-origin iframe
+ // over the cookieless /content/{token}/… capability route. The user's
+ // read permission was just checked above (getForUser*), so mint the
+ // bearer token here. Legacy (same-origin Service Worker) mode leaves
+ // this null and the viewer keeps its /runtime SW path.
+ $this->initialState->provideInitialState(
+ 'contentToken',
+ $secureIframe ? $this->contentTokens->mint((int)$file->getId(), $user->getUID()) : null,
+ );
}
+ $this->initialState->provideInitialState('secureIframe', $secureIframe);
+ // External-media relay config for the trusted parent (see relay-host.ts):
+ // 'strict' mode overlays only the maintained provider hosts.
+ $this->initialState->provideInitialState(
+ 'embedRelay',
+ $secureIframe
+ ? ['mode' => $this->sandbox->embedMode(), 'whitelist' => $this->sandbox->providerWhitelist()]
+ : null,
+ );
$this->initialState->provideInitialState(
'editorAvailable',
is_file(__DIR__ . '/../../js/editor/index.html'),
@@ -101,6 +124,12 @@ public function index(?int $fileId = null, ?string $path = null, ?string $mode =
$csp->addAllowedScriptDomain("'self'");
$csp->addAllowedConnectDomain("'self'");
$csp->addAllowedFrameDomain("'self'");
+ // Promoted players are cross-origin frames mounted on THIS page by the relay, so
+ // 'self' alone blocks every one of them. Allowed hosts come from the same list the
+ // relay is given, never a copy.
+ foreach ($this->sandbox->playerFrameDomains() as $host) {
+ $csp->addAllowedFrameDomain('https://' . $host);
+ }
$response->setContentSecurityPolicy($csp);
return $response;
}
diff --git a/lib/Service/ContentTokenService.php b/lib/Service/ContentTokenService.php
new file mode 100644
index 0000000..47ec09a
--- /dev/null
+++ b/lib/Service/ContentTokenService.php
@@ -0,0 +1,102 @@
+clock = $clock ?? static fn (): int => time();
+ }
+
+ /**
+ * Mint a capability token binding $fileId + $userId valid for $ttlSeconds.
+ * The user id is carried so the cookieless serving route can mount that
+ * user's storage (getUserFolder) — IRootFolder::getById alone resolves
+ * nothing without a filesystem context on a #[PublicPage].
+ */
+ public function mint(int $fileId, string $userId, int $ttlSeconds = 3600): string {
+ $expiry = ($this->clock)() + $ttlSeconds;
+ $payload = $fileId . '.' . $expiry . '.' . $this->b64($userId);
+ return $this->b64($payload) . '.' . $this->b64($this->sign($payload));
+ }
+
+ /**
+ * Verify a token: returns `[fileId, userId]` when the signature is valid and
+ * the token has not expired, otherwise null.
+ *
+ * @return array{0: int, 1: string}|null
+ */
+ public function verify(string $token): ?array {
+ $parts = explode('.', $token);
+ if (count($parts) !== 2) {
+ return null;
+ }
+ $payload = $this->unb64($parts[0]);
+ $signature = $this->unb64($parts[1]);
+ if ($payload === null || $signature === null) {
+ return null;
+ }
+ if (!hash_equals($this->sign($payload), $signature)) {
+ return null;
+ }
+ $segments = explode('.', $payload);
+ if (count($segments) !== 3) {
+ return null;
+ }
+ [$fileId, $expiry, $encodedUser] = $segments;
+ if (!ctype_digit($fileId) || !ctype_digit($expiry)) {
+ return null;
+ }
+ if ((int)$expiry <= ($this->clock)()) {
+ return null;
+ }
+ $userId = $this->unb64($encodedUser);
+ if ($userId === null || $userId === '') {
+ return null;
+ }
+ return [(int)$fileId, $userId];
+ }
+
+ private function sign(string $payload): string {
+ return hash_hmac('sha256', $payload, $this->secret(), true);
+ }
+
+ private function secret(): string {
+ return $this->secret . '|exelearning-content-v1';
+ }
+
+ private function b64(string $raw): string {
+ return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
+ }
+
+ private function unb64(string $encoded): ?string {
+ $decoded = base64_decode(strtr($encoded, '-_', '+/'), true);
+ return $decoded === false ? null : $decoded;
+ }
+}
diff --git a/lib/Service/EmbedShimInjector.php b/lib/Service/EmbedShimInjector.php
new file mode 100644
index 0000000..847c6e0
--- /dev/null
+++ b/lib/Service/EmbedShimInjector.php
@@ -0,0 +1,40 @@
+ as early as possible: before
+ * when present (it must run before the package's own media bridge),
+ * otherwise right after , otherwise prepended.
+ */
+ public function injectIntoHead(string $html, string $scriptSource): string {
+ $tag = '';
+
+ $headClose = stripos($html, '');
+ if ($headClose !== false) {
+ return substr($html, 0, $headClose) . $tag . substr($html, $headClose);
+ }
+
+ if (preg_match('~]*>~i', $html, $matches, PREG_OFFSET_CAPTURE) === 1) {
+ $at = $matches[0][1] + strlen($matches[0][0]);
+ return substr($html, 0, $at) . $tag . substr($html, $at);
+ }
+
+ return $tag . $html;
+ }
+}
diff --git a/lib/Service/EmbedShimSource.php b/lib/Service/EmbedShimSource.php
new file mode 100644
index 0000000..e24f7f1
--- /dev/null
+++ b/lib/Service/EmbedShimSource.php
@@ -0,0 +1,37 @@
+vendoredDir . '/' . self::CHILD_BUNDLE;
+ if (!is_file($path)) {
+ return null;
+ }
+ $source = file_get_contents($path);
+ return $source === false ? null : $source;
+ }
+}
diff --git a/lib/Service/IframeSandbox.php b/lib/Service/IframeSandbox.php
new file mode 100644
index 0000000..0936a23
--- /dev/null
+++ b/lib/Service/IframeSandbox.php
@@ -0,0 +1,164 @@
+envReader = $envReader ?? static function (string $name): ?string {
+ $value = getenv($name);
+ return $value === false ? null : $value;
+ };
+ }
+
+ /**
+ * Always 'secure' unless the dev escape hatch is explicitly on.
+ */
+ public function resolveMode(): string {
+ $raw = ($this->envReader)(self::LEGACY_ENV);
+ $on = $raw !== null && in_array(strtolower(trim($raw)), ['1', 'true', 'yes', 'on'], true);
+ return $on ? self::MODE_LEGACY : self::MODE_SECURE;
+ }
+
+ /**
+ * External-media relay mode. 'strict' (default, fail-safe) overlays only the
+ * maintained provider hosts; 'open' (opt-in via EXELEARNING_EMBED_OPEN)
+ * overlays any cross-origin https player the shim reports.
+ */
+ public function embedMode(): string {
+ $raw = ($this->envReader)(self::EMBED_OPEN_ENV);
+ $on = $raw !== null && in_array(strtolower(trim($raw)), ['1', 'true', 'yes', 'on'], true);
+ return $on ? self::EMBED_OPEN : self::EMBED_STRICT;
+ }
+
+ /**
+ * The iframe `sandbox` attribute token list for the given (or resolved) mode.
+ */
+ public function sandboxTokens(?string $mode = null): string {
+ $mode ??= $this->resolveMode();
+ return $mode === self::MODE_LEGACY ? self::LEGACY_TOKENS : self::SECURE_TOKENS;
+ }
+
+ /**
+ * Response-level CSP for served HTML package documents.
+ *
+ * 'strict' (default) keeps script/img/media/frame-src pinned to 'self' + the
+ * maintained providers so a URL-borne capability token can never be
+ * exfiltrated to an arbitrary host; the trailing `sandbox` directive keeps
+ * the document opaque even if opened top-level. 'compatible' re-opens https:
+ * for deployments that need arbitrary external assets.
+ */
+ public function contentSecurityPolicy(string $profile = 'strict'): string {
+ $scriptSrc = "script-src 'self' 'unsafe-inline' 'unsafe-eval'";
+ $imgSrc = "img-src 'self' data: blob:";
+ $mediaSrc = "media-src 'self' data: blob:";
+ $frameSrc = "frame-src 'self' https://www.youtube-nocookie.com https://player.vimeo.com "
+ . 'https://www.dailymotion.com https://mediateca.educa.madrid.org';
+ if ($profile === 'compatible') {
+ $scriptSrc = "script-src 'self' 'unsafe-inline' 'unsafe-eval' https:";
+ $imgSrc = "img-src 'self' data: blob: https:";
+ $mediaSrc = "media-src 'self' data: blob: https:";
+ $frameSrc = "frame-src 'self' https:";
+ }
+ return implode('; ', [
+ "default-src 'self'",
+ $scriptSrc,
+ "style-src 'self' 'unsafe-inline'",
+ $imgSrc,
+ $mediaSrc,
+ "font-src 'self' data:",
+ "connect-src 'self'",
+ $frameSrc,
+ "frame-ancestors 'self'",
+ "form-action 'self'",
+ "base-uri 'self'",
+ "object-src 'none'",
+ 'sandbox ' . self::SECURE_TOKENS,
+ ]);
+ }
+
+ /**
+ * Locked, script-free CSP for SVG/XML documents opened top-level.
+ */
+ public function svgCsp(): string {
+ return "default-src 'none'; style-src 'unsafe-inline'; img-src data:; script-src 'none'; sandbox";
+ }
+
+ /**
+ * Deny hardware access; deliberately does NOT deny fullscreen (video needs it).
+ */
+ public function permissionsPolicy(): string {
+ return 'camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=(), '
+ . 'bluetooth=(), hid=(), magnetometer=(), accelerometer=(), gyroscope=(), '
+ . 'midi=(), display-capture=()';
+ }
+
+ /**
+ * @return string[]
+ */
+ public function providerWhitelist(): array {
+ return self::PROVIDER_WHITELIST;
+ }
+
+ /**
+ * Hosts the page CSP must allow as frame sources.
+ *
+ * The relay overlays real players for exactly these hosts, on the trusted page. If the
+ * CSP is narrower the browser blocks the frame and the learner gets a black rectangle
+ * with nothing in the console to explain it, so this deliberately returns the same
+ * list rather than a second one that could drift from it.
+ *
+ * @return string[]
+ */
+ public function playerFrameDomains(): array {
+ return $this->providerWhitelist();
+ }
+}
diff --git a/lib/Service/PackageMimeService.php b/lib/Service/PackageMimeService.php
new file mode 100644
index 0000000..06fd84e
--- /dev/null
+++ b/lib/Service/PackageMimeService.php
@@ -0,0 +1,69 @@
+ 'text/html; charset=utf-8',
+ 'htm' => 'text/html; charset=utf-8',
+ 'xhtml' => 'application/xhtml+xml; charset=utf-8',
+ 'xml' => 'application/xml; charset=utf-8',
+ 'css' => 'text/css; charset=utf-8',
+ 'js' => 'text/javascript; charset=utf-8',
+ 'mjs' => 'text/javascript; charset=utf-8',
+ 'json' => 'application/json; charset=utf-8',
+ 'svg' => 'image/svg+xml',
+ 'png' => 'image/png',
+ 'jpg' => 'image/jpeg',
+ 'jpeg' => 'image/jpeg',
+ 'gif' => 'image/gif',
+ 'webp' => 'image/webp',
+ 'mp3' => 'audio/mpeg',
+ 'mp4' => 'video/mp4',
+ 'ogg' => 'audio/ogg',
+ 'wav' => 'audio/wav',
+ 'webm' => 'video/webm',
+ 'vtt' => 'text/vtt',
+ 'woff' => 'font/woff',
+ 'woff2' => 'font/woff2',
+ 'ttf' => 'font/ttf',
+ 'eot' => 'application/vnd.ms-fontobject',
+ ];
+
+ public function detect(string $entry): string {
+ $extension = strtolower(pathinfo($entry, PATHINFO_EXTENSION));
+ return self::MIME_MAP[$extension] ?? 'application/octet-stream';
+ }
+
+ /**
+ * A "page" document that runs the eXe engine — gets the full published CSP
+ * and the injected media shim.
+ */
+ public function isHtmlDocument(string $mime): bool {
+ $type = $this->baseType($mime);
+ return $type === 'text/html' || $type === 'application/xhtml+xml';
+ }
+
+ /**
+ * A data document (SVG/XML) that must be locked script-free when opened
+ * top-level (an author SVG runs inline ', $out);
+ }
+
+ public function testPreservesRegexSpecialCharsInSource(): void {
+ $source = 'var a = "$1"; var b = "\\\\n"; // $0 \\1';
+ $out = $this->injector->injectIntoHead('', $source);
+ self::assertStringContainsString($source, $out);
+ }
+
+ public function testFallsBackToAfterBodyWhenNoHead(): void {
+ $out = $this->injector->injectIntoHead('hi', 'Y');
+ self::assertStringContainsString('', $out);
+ }
+
+ public function testPrependsWhenNoHeadNoBody(): void {
+ $out = $this->injector->injectIntoHead('
bare
', 'Z');
+ self::assertStringStartsWith('', $out);
+ }
+}
diff --git a/tests/Unit/Service/ExternalMediaArtifactTest.php b/tests/Unit/Service/ExternalMediaArtifactTest.php
new file mode 100644
index 0000000..30db421
--- /dev/null
+++ b/tests/Unit/Service/ExternalMediaArtifactTest.php
@@ -0,0 +1,62 @@
+read();
+
+ self::assertNotNull($source, 'no shim source was produced');
+ self::assertSame(
+ file_get_contents(self::VENDORED . '/exe-external-media-child.min.js'),
+ $source,
+ );
+ }
+
+ public function testServedShimPublishesTheSharedBridgeContract(): void {
+ $source = (new EmbedShimSource(self::VENDORED))->read() ?? '';
+
+ // The three symbols the host and the interactive-video iDevice rely on.
+ self::assertStringContainsString('exeExternalMediaChild', $source);
+ self::assertStringContainsString('exeEmbedShim', $source);
+ self::assertStringContainsString('exeMediaBridge', $source);
+ // And the licence notice ADR-0018 requires to survive minification.
+ self::assertStringContainsString('Dual-licensed', $source);
+ }
+
+ public function testMissingArtifactYieldsNullRatherThanAFatal(): void {
+ self::assertNull((new EmbedShimSource(__DIR__ . '/does-not-exist'))->read());
+ }
+
+ /**
+ * Byte identity against the manifest core published. Integrity, not provenance —
+ * the CI step pins the buildHash out of band — but it catches a local edit, which is
+ * the failure this repo can actually cause on its own.
+ */
+ public function testVendoredBytesMatchTheManifest(): void {
+ $manifest = json_decode((string)file_get_contents(self::VENDORED . '/exe-external-media.manifest.json'), true);
+ self::assertIsArray($manifest['files'] ?? null);
+
+ foreach ($manifest['files'] as $key => $record) {
+ $path = self::VENDORED . '/' . $record['path'];
+ self::assertFileExists($path, "vendored $key is missing");
+ self::assertSame($record['sha256'], hash_file('sha256', $path), "vendored $key was edited locally");
+ }
+ }
+}
diff --git a/tests/Unit/Service/IframeSandboxTest.php b/tests/Unit/Service/IframeSandboxTest.php
new file mode 100644
index 0000000..5ea0451
--- /dev/null
+++ b/tests/Unit/Service/IframeSandboxTest.php
@@ -0,0 +1,128 @@
+ $env Environment values keyed by variable name.
+ */
+ private function service(array $env = []): IframeSandbox {
+ return new IframeSandbox(static fn (string $name): ?string => $env[$name] ?? null);
+ }
+
+ public function testDefaultsToSecureMode(): void {
+ self::assertSame(IframeSandbox::MODE_SECURE, $this->service()->resolveMode());
+ }
+
+ public function testLegacyModeOnlyViaEscapeHatch(): void {
+ $hatch = 'EXELEARNING_UNSAFE_LEGACY_IFRAME';
+ self::assertSame(IframeSandbox::MODE_LEGACY, $this->service([$hatch => '1'])->resolveMode());
+ self::assertSame(IframeSandbox::MODE_LEGACY, $this->service([$hatch => 'true'])->resolveMode());
+ self::assertSame(IframeSandbox::MODE_SECURE, $this->service([$hatch => '0'])->resolveMode());
+ self::assertSame(IframeSandbox::MODE_SECURE, $this->service([$hatch => ''])->resolveMode());
+ }
+
+ public function testEmbedModeDefaultsToStrict(): void {
+ self::assertSame(IframeSandbox::EMBED_STRICT, $this->service()->embedMode());
+ }
+
+ public function testEmbedModeOpensOnlyViaEnv(): void {
+ $env = 'EXELEARNING_EMBED_OPEN';
+ self::assertSame(IframeSandbox::EMBED_OPEN, $this->service([$env => '1'])->embedMode());
+ self::assertSame(IframeSandbox::EMBED_STRICT, $this->service([$env => 'off'])->embedMode());
+ }
+
+ public function testSecureSandboxTokensNeverAllowSameOrigin(): void {
+ $tokens = $this->service()->sandboxTokens(IframeSandbox::MODE_SECURE);
+ self::assertSame('allow-scripts allow-popups allow-forms', $tokens);
+ self::assertStringNotContainsString('allow-same-origin', $tokens);
+ self::assertStringNotContainsString('allow-popups-to-escape-sandbox', $tokens);
+ self::assertStringNotContainsString('allow-top-navigation', $tokens);
+ }
+
+ public function testLegacySandboxTokensReintroduceSameOrigin(): void {
+ $tokens = $this->service()->sandboxTokens(IframeSandbox::MODE_LEGACY);
+ self::assertStringContainsString('allow-same-origin', $tokens);
+ }
+
+ public function testStrictPublishedCspShape(): void {
+ $csp = $this->service()->contentSecurityPolicy();
+ // The opaque sandbox rides in the CSP too (keeps the doc opaque if opened top-level).
+ self::assertStringContainsString('sandbox allow-scripts allow-popups allow-forms', $csp);
+ self::assertStringContainsString("object-src 'none'", $csp);
+ self::assertStringContainsString("frame-ancestors 'self'", $csp);
+ // Only the maintained providers, never bare https: in frame-src.
+ self::assertStringContainsString('https://www.youtube-nocookie.com', $csp);
+ self::assertStringContainsString('https://mediateca.educa.madrid.org', $csp);
+ self::assertStringNotContainsString('frame-src \'self\' https:;', $csp);
+ // No token-exfiltrating bare https: in script/img/media in the strict profile.
+ self::assertDoesNotMatchRegularExpression('~script-src[^;]*\bhttps:(?!//)~', $csp);
+ }
+
+ public function testCompatibleProfileReopensHttps(): void {
+ $csp = $this->service()->contentSecurityPolicy('compatible');
+ self::assertStringContainsString("script-src 'self' 'unsafe-inline' 'unsafe-eval' https:", $csp);
+ self::assertStringContainsString('frame-src \'self\' https:', $csp);
+ // Sandbox stays even in the weaker profile.
+ self::assertStringContainsString('sandbox allow-scripts allow-popups allow-forms', $csp);
+ }
+
+ public function testSvgCspIsScriptFree(): void {
+ $csp = $this->service()->svgCsp();
+ self::assertStringContainsString("script-src 'none'", $csp);
+ self::assertStringContainsString("default-src 'none'", $csp);
+ self::assertStringContainsString('sandbox', $csp);
+ }
+
+ public function testPermissionsPolicyDeniesHardwareButNotFullscreen(): void {
+ $pp = $this->service()->permissionsPolicy();
+ self::assertStringContainsString('camera=()', $pp);
+ self::assertStringContainsString('microphone=()', $pp);
+ self::assertStringContainsString('geolocation=()', $pp);
+ self::assertStringNotContainsString('fullscreen', $pp);
+ }
+
+ public function testProviderWhitelistIsLowercaseAndDeduped(): void {
+ $hosts = $this->service()->providerWhitelist();
+ self::assertContains('www.youtube-nocookie.com', $hosts);
+ self::assertContains('player.vimeo.com', $hosts);
+ self::assertContains('mediateca.educa.madrid.org', $hosts);
+ self::assertSame(array_values(array_unique($hosts)), $hosts);
+ self::assertSame(array_map('strtolower', $hosts), $hosts);
+ }
+
+ /**
+ * The page CSP must allow exactly the hosts the relay is allowed to promote.
+ *
+ * These are two halves of one decision kept in two places, and the failure when they
+ * drift is silent and ugly: the relay overlays a real player, the browser refuses to
+ * load a frame the CSP never allowed, and the learner gets a permanent black
+ * rectangle where the video should be. That is exactly what the viewer shipped —
+ * `frame-src 'self'` with a ten-host provider whitelist next to it.
+ */
+ public function testPlayerFrameDomainsCoverEveryWhitelistedProvider(): void {
+ $sandbox = new IframeSandbox();
+
+ $domains = $sandbox->playerFrameDomains();
+
+ foreach ($sandbox->providerWhitelist() as $host) {
+ self::assertContains($host, $domains, "CSP would block a promoted player on $host");
+ }
+ }
+
+ /** And nothing beyond them: the CSP is not a place to widen the gate quietly. */
+ public function testPlayerFrameDomainsAddNothingBeyondTheWhitelist(): void {
+ $sandbox = new IframeSandbox();
+
+ self::assertSame([], array_diff($sandbox->playerFrameDomains(), $sandbox->providerWhitelist()));
+ }
+}
diff --git a/tests/Unit/Service/PackageMimeServiceTest.php b/tests/Unit/Service/PackageMimeServiceTest.php
new file mode 100644
index 0000000..850fcfc
--- /dev/null
+++ b/tests/Unit/Service/PackageMimeServiceTest.php
@@ -0,0 +1,42 @@
+service = new PackageMimeService();
+ }
+
+ public function testDetectsKnownExtensions(): void {
+ self::assertSame('text/html; charset=utf-8', $this->service->detect('index.html'));
+ self::assertSame('image/svg+xml', $this->service->detect('img/logo.svg'));
+ self::assertSame('text/javascript; charset=utf-8', $this->service->detect('a.js'));
+ self::assertSame('application/xml; charset=utf-8', $this->service->detect('content.xml'));
+ }
+
+ public function testUnknownExtensionFallsBack(): void {
+ self::assertSame('application/octet-stream', $this->service->detect('weird.qux'));
+ self::assertSame('application/octet-stream', $this->service->detect('README'));
+ }
+
+ public function testHtmlDocumentsGetTheEnginePolicy(): void {
+ self::assertTrue($this->service->isHtmlDocument('text/html; charset=utf-8'));
+ self::assertTrue($this->service->isHtmlDocument('application/xhtml+xml'));
+ self::assertFalse($this->service->isHtmlDocument('image/svg+xml'));
+ self::assertFalse($this->service->isHtmlDocument('text/css'));
+ }
+
+ public function testXmlDocumentsAreLocked(): void {
+ self::assertTrue($this->service->isLockedXml('image/svg+xml'));
+ self::assertTrue($this->service->isLockedXml('application/xml; charset=utf-8'));
+ self::assertFalse($this->service->isLockedXml('text/html'));
+ self::assertFalse($this->service->isLockedXml('image/png'));
+ }
+}
diff --git a/tests/Unit/Service/Preview/PreviewPolicyTest.php b/tests/Unit/Service/Preview/PreviewPolicyTest.php
new file mode 100644
index 0000000..cea0843
--- /dev/null
+++ b/tests/Unit/Service/Preview/PreviewPolicyTest.php
@@ -0,0 +1,149 @@
+
+ */
+ public static function scriptableTypes(): iterable {
+ yield ['text/html'];
+ yield ['text/html; charset=utf-8'];
+ yield ['image/svg+xml'];
+ yield ['image/svg+xml; charset=utf-8'];
+ yield ['application/xml'];
+ yield ['application/xhtml+xml; charset=utf-8'];
+ }
+
+ /**
+ * @dataProvider nonScriptableTypes
+ */
+ public function testNonScriptableTypesAreNotScriptable(string $mime): void {
+ self::assertFalse(PreviewPolicy::isScriptable($mime));
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function nonScriptableTypes(): iterable {
+ yield ['image/png'];
+ yield ['text/css; charset=utf-8'];
+ yield ['application/json; charset=utf-8'];
+ yield ['video/mp4'];
+ yield ['application/octet-stream'];
+ }
+
+ /**
+ * @dataProvider mimeCases
+ */
+ public function testMimeForPath(string $path, string $expected): void {
+ self::assertSame($expected, PreviewPolicy::mimeForPath($path));
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function mimeCases(): iterable {
+ yield 'html carries charset' => ['index.html', 'text/html; charset=utf-8'];
+ yield 'png is bare' => ['content/resources/photo.png', 'image/png'];
+ yield 'svg is charset-tagged (scriptable-tab hole)' => ['theme/icon.svg', 'image/svg+xml; charset=utf-8'];
+ yield 'js carries charset' => ['libs/jquery/jquery.min.js', 'text/javascript; charset=utf-8'];
+ yield 'json carries charset' => ['data/index.json', 'application/json; charset=utf-8'];
+ yield 'mp4 is bare' => ['media/clip.mp4', 'video/mp4'];
+ yield 'unknown falls back to octet-stream' => ['blob.bin', 'application/octet-stream'];
+ }
+
+ /**
+ * @dataProvider safePaths
+ */
+ public function testNormalizePathAcceptsSafePaths(string $raw, string $expected): void {
+ self::assertSame($expected, PreviewPolicy::normalizePath($raw));
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function safePaths(): iterable {
+ yield 'plain file' => ['index.html', 'index.html'];
+ yield 'empty defaults to index' => ['', 'index.html'];
+ yield 'leading slash stripped' => ['/html/page-2.html', 'html/page-2.html'];
+ yield 'dot segments collapsed' => ['a/./b.css', 'a/b.css'];
+ yield 'double slash collapsed' => ['a//b.css', 'a/b.css'];
+ yield 'backslash folded to slash' => ['a\\b.css', 'a/b.css'];
+ yield 'query stripped' => ['page.html?v=3', 'page.html'];
+ }
+
+ /**
+ * @dataProvider unsafePaths
+ */
+ public function testNormalizePathRejectsUnsafePaths(string $raw): void {
+ self::assertNull(PreviewPolicy::normalizePath($raw));
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function unsafePaths(): iterable {
+ yield 'literal parent' => ['../secret'];
+ yield 'percent-encoded parent' => ['%2e%2e%2fsecret'];
+ yield 'internal parent escape' => ['a/../../secret'];
+ yield 'bare dotdot' => ['..'];
+ yield 'trailing parent collapses to empty root' => ['foo/..'];
+ yield 'nul byte' => ["a\0b"];
+ }
+
+ public function testPreviewIdValidation(): void {
+ self::assertTrue(PreviewPolicy::isValidPreviewId('3f2a1b4c-5d6e-4f70-8a90-b1c2d3e4f506'));
+ self::assertFalse(PreviewPolicy::isValidPreviewId('not-a-uuid'));
+ self::assertFalse(PreviewPolicy::isValidPreviewId('3F2A1B4C-5D6E-4F70-8A90-B1C2D3E4F506'));
+ self::assertFalse(PreviewPolicy::isValidPreviewId('../../etc/passwd'));
+ }
+
+}
diff --git a/tests/Unit/Service/Preview/PreviewServerTest.php b/tests/Unit/Service/Preview/PreviewServerTest.php
new file mode 100644
index 0000000..e80edc0
--- /dev/null
+++ b/tests/Unit/Service/Preview/PreviewServerTest.php
@@ -0,0 +1,250 @@
+root = sys_get_temp_dir() . '/exe-srv-' . bin2hex(random_bytes(6));
+ $this->store = new PreviewSnapshotStore($this->root, new PreviewSnapshotLimits());
+ $this->previewId = $this->store->replace('alice', $this->zip([
+ 'index.html' => 'hi',
+ 'img/inline.svg' => '',
+ 'content/resources/photo.png' => 'PHOTO-BYTES-v1',
+ 'media/clip.mp4' => '0123456789',
+ ]))['previewId'];
+ }
+
+ protected function tearDown(): void {
+ $this->removeTree($this->root);
+ }
+
+ /**
+ * Build a snapshot ZIP on disk from a path => contents map.
+ *
+ * @param array $entries
+ */
+ private function zip(array $entries): string {
+ $path = sys_get_temp_dir() . '/exe-srv-zip-' . bin2hex(random_bytes(6)) . '.zip';
+ $zip = new ZipArchive();
+ $zip->open($path, ZipArchive::CREATE);
+ foreach ($entries as $name => $contents) {
+ $zip->addFromString($name, $contents);
+ }
+ $zip->close();
+ return $path;
+ }
+
+ private function server(): PreviewServer {
+ return new PreviewServer($this->store);
+ }
+
+ /** The ETag the store minted for a path (content-independent, so read it back). */
+ private function etagFor(string $path): string {
+ return $this->server()->serve($this->previewId, $path)->headers['ETag'];
+ }
+
+ private function assertBaseHardening(array $headers): void {
+ self::assertSame('nosniff', $headers['X-Content-Type-Options']);
+ self::assertSame('no-referrer', $headers['Referrer-Policy']);
+ self::assertSame('camera=(), microphone=(), geolocation=(), payment=()', $headers['Permissions-Policy']);
+ self::assertSame('*', $headers['Access-Control-Allow-Origin']);
+ }
+
+ public function testServeDocument(): void {
+ $response = $this->server()->serve($this->previewId, 'index.html');
+ self::assertSame(200, $response->status);
+ self::assertSame('hi', $response->body);
+ self::assertSame('text/html; charset=utf-8', $response->headers['Content-Type']);
+ self::assertSame('no-store', $response->headers['Cache-Control']);
+ self::assertSame(PreviewPolicy::CSP, $response->headers['Content-Security-Policy']);
+ $this->assertBaseHardening($response->headers);
+ }
+
+ public function testServeAssetHasEtagNoCacheAndNoCsp(): void {
+ $response = $this->server()->serve($this->previewId, 'content/resources/photo.png');
+ self::assertSame(200, $response->status);
+ self::assertSame('PHOTO-BYTES-v1', $response->body);
+ self::assertSame('image/png', $response->headers['Content-Type']);
+ self::assertSame('no-cache', $response->headers['Cache-Control']);
+ self::assertSame('bytes', $response->headers['Accept-Ranges']);
+ self::assertArrayHasKey('ETag', $response->headers);
+ self::assertArrayNotHasKey('Content-Security-Policy', $response->headers);
+ }
+
+ public function testConditionalRequestReturns304(): void {
+ $etag = $this->etagFor('content/resources/photo.png');
+ $response = $this->server()->serve($this->previewId, 'content/resources/photo.png', $etag);
+ self::assertSame(304, $response->status);
+ self::assertSame('', $response->body);
+ self::assertSame('nosniff', $response->headers['X-Content-Type-Options']);
+ self::assertSame($etag, $response->headers['ETag']);
+ }
+
+ /**
+ * The ETag is built from identity rather than from hashing the bytes, so it
+ * has to turn over on a refresh that mtime and size alone cannot see: two
+ * publishes inside the same second where the file keeps its length.
+ */
+ public function testEtagTurnsOverOnASameSizeRefresh(): void {
+ $before = $this->etagFor('media/clip.mp4');
+
+ $this->store->replace('alice', $this->zip([
+ 'index.html' => '',
+ 'media/clip.mp4' => '9876543210',
+ ]), $this->previewId);
+
+ $response = $this->server()->serve(
+ $this->previewId,
+ 'media/clip.mp4',
+ $before,
+ );
+ self::assertSame(200, $response->status, 'a stale ETag must not win a conditional request');
+ self::assertSame('9876543210', $response->body);
+ self::assertNotSame($before, $response->headers['ETag']);
+ }
+
+ public function testSingleRangeReturns206(): void {
+ $response = $this->server()->serve($this->previewId, 'media/clip.mp4', null, 'bytes=2-4');
+ self::assertSame(206, $response->status);
+ self::assertSame('234', $response->body);
+ self::assertSame('bytes 2-4/10', $response->headers['Content-Range']);
+ self::assertSame('3', $response->headers['Content-Length']);
+ }
+
+ /**
+ * 416 is reserved for a valid single range that is unsatisfiable: a
+ * first-byte-pos at/after EOF (`bytes=99-`) or a zero-length suffix
+ * (`bytes=-0`).
+ *
+ * @dataProvider unsatisfiableRangeProvider
+ */
+ public function testUnsatisfiableRangeReturns416(string $range): void {
+ $response = $this->server()->serve($this->previewId, 'media/clip.mp4', null, $range);
+ self::assertSame(416, $response->status, $range . ' must be 416');
+ self::assertSame('bytes */10', $response->headers['Content-Range']);
+ }
+
+ /** @return array */
+ public static function unsatisfiableRangeProvider(): array {
+ return [
+ 'first-byte-pos past EOF' => ['bytes=99-'],
+ 'zero-length suffix' => ['bytes=-0'],
+ ];
+ }
+
+ /**
+ * A malformed, multi-range or non-bytes Range header is IGNORED — the server
+ * serves a normal 200 full body, never 416.
+ *
+ * @dataProvider ignoredRangeProvider
+ */
+ public function testIgnoredRangeServesFull200(string $range): void {
+ $response = $this->server()->serve($this->previewId, 'media/clip.mp4', null, $range);
+ self::assertSame(200, $response->status, $range . ' must be ignored, not 416');
+ self::assertSame('0123456789', $response->body);
+ self::assertSame('bytes', $response->headers['Accept-Ranges']);
+ self::assertArrayNotHasKey('Content-Range', $response->headers);
+ }
+
+ /** @return array */
+ public static function ignoredRangeProvider(): array {
+ return [
+ 'non-numeric' => ['bytes=abc'],
+ 'multi-range' => ['bytes=0-1,2-3'],
+ 'non-bytes unit' => ['items=0-1'],
+ 'empty range' => ['bytes=-'],
+ 'garbage' => ['not-a-range'],
+ 'double dash' => ['bytes=1-2-3'],
+ // last-byte-pos < first-byte-pos is an invalid spec (RFC 9110
+ // §14.1.2) → ignore the header, not 416.
+ 'last before first' => ['bytes=5-2'],
+ ];
+ }
+
+ public function testBareRootRedirectsToIndexHtml(): void {
+ $response = $this->server()->serveRoot($this->previewId);
+ self::assertSame(302, $response->status);
+ // Relative Location so it stays correct under any webroot: it resolves
+ // against the request URI `.../preview/{previewId}`.
+ self::assertSame($this->previewId . '/index.html', $response->headers['Location']);
+ self::assertSame('no-store', $response->headers['Cache-Control']);
+ $this->assertBaseHardening($response->headers);
+ }
+
+ public function testBareRootInvalidPreviewIdIs404(): void {
+ $response = $this->server()->serveRoot('not-a-uuid');
+ self::assertSame(404, $response->status);
+ self::assertArrayNotHasKey('Location', $response->headers);
+ $this->assertBaseHardening($response->headers);
+ }
+
+ /**
+ * An author-supplied SVG runs its inline