From 77bf3d381ae6cffb39ea4c8bab6e67417d495c5c Mon Sep 17 00:00:00 2001 From: erseco Date: Mon, 6 Jul 2026 13:19:25 +0200 Subject: [PATCH 01/43] feat(preview): host-served opaque HTTP preview serving contract (reference) Mirror of the eXeLearning core canonical contract (exelearning/doc/development/preview-serving-contract.md): serve the editor preview of untrusted author content over an authless capability URL in an opaque origin, via this host's own cookieless serving primitive, so the preview gets real per-page URLs (working navigation + open-in-new-tab) instead of the srcdoc fallback. The sandbox-first CSP is emitted verbatim from core's previewCspHeader() on every scriptable document type (text/html, image/svg+xml, application/xml, application/xhtml+xml). Reference endpoint + docs; the session store, management API and tests are follow-up per repo. --- docs/preview-serving-contract.md | 132 +++++++++++++++++++++++ lib/Controller/PreviewController.php | 151 +++++++++++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 docs/preview-serving-contract.md create mode 100644 lib/Controller/PreviewController.php diff --git a/docs/preview-serving-contract.md b/docs/preview-serving-contract.md new file mode 100644 index 0000000..6b01ec7 --- /dev/null +++ b/docs/preview-serving-contract.md @@ -0,0 +1,132 @@ +# Host-served opaque HTTP preview (serving contract) + +This Nextcloud app can act as a **preview host** for the eXeLearning editor: it +serves the *editor preview* of untrusted, in-progress author content over HTTP +from an **opaque origin**, isolated from the Nextcloud session. + +This document is the host-side companion to the canonical contract in eXe core: +**`doc/development/preview-serving-contract.md`** (repo `exelearning/exelearning`). +The core doc is authoritative. If the two ever disagree, core wins — in +particular the sandbox CSP string below must stay **byte-identical** to core. + +## Why an HTTP transport (and not the Service Worker) + +For *published* `.elpx` content this app already serves package bytes two ways: +the in-browser Service Worker (`src/sw/exelearning-sw.js`) and the authenticated +fallback `AssetController::fetch` (`/asset/{sessionId}/{path}`). Both are +**same-origin** and are 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. So the editor preview MUST be served from a **separate, authless, +cookieless origin** and never through the Service Worker. That is what this +contract provides. + +## What this reuses vs. what is new + +Reused idioms from the published-content path: +- The `DataDisplayResponse` + raw `addHeader(...)` serving idiom from + `lib/Controller/AssetController.php`. +- Path-safety via `ZipEntryService::normalizeEntry()` (rejects `..`, `.`, + absolute and NUL-tainted paths). +- The small extension→MIME map idiom from `AssetController::MIME_MAP`. + +New in this contract (follow-up implementation): +- A `#[PublicPage]` **authless** serving route (the app has none today). +- A **content-addressed, per-session store** (not a single `.elpx`). +- The **sandbox-first CSP** applied to scriptable document types. + +## Serving route (AUTHLESS capability URL) + +``` +GET {previewBasePath}/preview/{previewId}/{path} +``` + +- `previewId` MUST match + `^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`. + Anything else → **404** (with the hardening headers, see below). +- The bare `/preview/{previewId}/` resolves `path` to `index.html`. +- No auth, no CSRF, **no credentials** — the response carries + `Access-Control-Allow-Origin: *`, which is only sound because the origin is + cookieless. The route must never emit a Nextcloud session cookie. + +Reference implementation skeleton: **`lib/Controller/PreviewController.php`**. + +## Required response headers (on EVERY response, including 404) + +| Header | Value | +| --- | --- | +| `X-Content-Type-Options` | `nosniff` | +| `Referrer-Policy` | `no-referrer` | +| `Cache-Control` | `no-store` | +| `Permissions-Policy` | `camera=(), microphone=(), geolocation=(), payment=()` | +| `Access-Control-Allow-Origin` | `*` (never together with credentials) | +| `Content-Type` | the real MIME of the served blob | + +## Sandbox CSP (scriptable document types only) + +On every **scriptable** document type — `text/html`, `image/svg+xml`, +`application/xml`, `application/xhtml+xml` — add this `Content-Security-Policy` +header **verbatim** (byte-identical to eXe core): + +``` +sandbox allow-scripts allow-popups allow-forms; default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; media-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self'; frame-src 'self' https://www.youtube-nocookie.com https://player.vimeo.com; child-src 'self' https://www.youtube-nocookie.com https://player.vimeo.com; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; +``` + +The leading `sandbox` directive drops the content into an opaque, unique origin; +the rest is defence-in-depth. Do **not** re-order, re-quote, or reformat it — +keep it in one shared constant so the published-content path and this preview +path stay identical. + +## Capability + lifecycle model + +- **Capability UUID.** Knowledge of `previewId` is the only bearer of access. + Generate a v4 UUID per session; treat it as a secret; never log it. +- **Idle TTL.** A session expires after **30 min** of inactivity; expired → + 404. Every serve refreshes the timer. +- **Caps (defaults).** 5000 files and 200 MiB per session; 2 GiB global; reject + writes past the cap. +- **Content-addressed store.** Blobs are keyed by SHA-256, **re-hashed + server-side** on upload; hash-mismatched blobs are quarantined, never served. + Manifest swaps are atomic. + +## Management API (AUTHENTICATED, owner-scoped) — follow-up + +Served under the normal authenticated app routes (NOT the public origin): + +- `POST /api/preview-session` → `{ previewId, previewBasePath, limits }` +- `POST /api/preview-session/{id}/manifest` `{files:{path:{sha256,size}}}` + → `{ manifestId, missing[], active }` +- `POST /api/preview-session/{id}/blobs` (multipart `manifestId`+`hashes`+files, + re-hash server-side) → `{ stored[], mismatched[], active }` +- `DELETE /api/preview-session/{id}` + +## Editor activation + +The editor turns this on by pointing its embedding config at this host: + +```js +window.__EXE_EMBEDDING_CONFIG__ = Object.assign(existingConfig, { + previewTransport: 'http', // never 'sw' for untrusted preview + previewBasePath: '/apps/exelearning', // absolute URL from IURLGenerator +}); +``` + +`previewBasePath` is returned by `POST /api/preview-session` +(`IURLGenerator::linkToRouteAbsolute('exelearning.preview.serve', …)`, trimmed to +the app root). The editor then loads `GET {previewBasePath}/preview/{previewId}/…` +inside its preview iframe. + +## Status / follow-up + +The serving endpoint in `lib/Controller/PreviewController.php` is a grounded +**skeleton**. Still to build (own PR, with tests): +1. `PreviewSessionStore` service (content-addressed store, caps, idle TTL, atomic + manifest swap). +2. `PreviewSessionController` (the authenticated management API above). +3. `SANDBOX_CSP` extracted into one shared constant, reused by the + published-content path. +4. PHPUnit coverage under `tests/Unit/Controller/PreviewControllerTest.php` + (UUID validation, header set incl. 404, CSP-on-scriptable-types) — patch + coverage ≥ 90%. \ No newline at end of file diff --git a/lib/Controller/PreviewController.php b/lib/Controller/PreviewController.php new file mode 100644 index 0000000..c3170ec --- /dev/null +++ b/lib/Controller/PreviewController.php @@ -0,0 +1,151 @@ + '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' => '.+', + * ], + * 'defaults' => ['path' => 'index.html'], + * ] + * plus a sibling route for the bare `/preview/{previewId}/` root that also + * defaults `path` to `index.html`. + */ + #[PublicPage] + #[NoCSRFRequired] + public function serve(string $previewId, string $path = 'index.html'): DataDisplayResponse { + // 1. Validate the capability UUID. Invalid → 404 (still hardened). + if (preg_match(self::PREVIEW_ID_RE, $previewId) !== 1) { + return $this->notFound(); + } + + // 2. Resolve the blob from the per-session content-addressed store. + // The store owns path-safety (reuse ZipEntryService::normalizeEntry + // semantics), idle-TTL expiry, caps, and returns the *real* MIME it + // recorded when the blob was re-hashed on upload. + $blob = $this->lookup($previewId, $path); + if ($blob === null) { + return $this->notFound(); + } + [$bytes, $mime] = $blob; + + // 3. Serve with the hardening header set; add the sandbox CSP only on + // scriptable document types. + $response = new DataDisplayResponse($bytes, Http::STATUS_OK, ['Content-Type' => $mime]); + $this->harden($response); + if ($this->isScriptable($mime)) { + $response->addHeader('Content-Security-Policy', self::SANDBOX_CSP); + } + return $response; + } + + /** + * Store lookup. Returns `[bytes, mime]` or null when the session/blob is + * missing, expired, or was quarantined for a hash mismatch. + * + * TODO(follow-up): replace with PreviewSessionStore. The real store: + * - refreshes the session idle TTL (default 30 min) on each hit, + * - normalises `$path` (reject `..`/`.`/absolute/NUL — see + * ZipEntryService::normalizeEntry), + * - maps the manifest path to a SHA-256-addressed blob, + * - returns the MIME captured at upload time (never sniffed here). + * + * @return array{0: string, 1: string}|null + */ + private function lookup(string $previewId, string $path): ?array { + // Stub: no store wired yet. + unset($previewId, $path); + return null; + } + + /** 404 body with the full hardening header set (contract: headers on 404 too). */ + private function notFound(): DataDisplayResponse { + $response = new DataDisplayResponse('Not Found', Http::STATUS_NOT_FOUND, ['Content-Type' => 'text/plain; charset=utf-8']); + $this->harden($response); + return $response; + } + + /** Applies the mandatory hardening headers required on every response. */ + private function harden(DataDisplayResponse $response): void { + $response->addHeader('X-Content-Type-Options', 'nosniff'); + $response->addHeader('Referrer-Policy', 'no-referrer'); + $response->addHeader('Cache-Control', 'no-store'); + $response->addHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()'); + // Opaque, cookieless origin — safe to allow any reader, never with credentials. + $response->addHeader('Access-Control-Allow-Origin', '*'); + } + + private function isScriptable(string $mime): bool { + $type = strtolower(trim(explode(';', $mime, 2)[0])); + return in_array($type, self::SCRIPTABLE_TYPES, true); + } +} \ No newline at end of file From 0c411a8564afddd92d1a9f154ec403be6cbeea44 Mon Sep 17 00:00:00 2001 From: erseco Date: Mon, 6 Jul 2026 18:14:50 +0200 Subject: [PATCH 02/43] fix(preview): add trailing newline to PreviewController.php --- lib/Controller/PreviewController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Controller/PreviewController.php b/lib/Controller/PreviewController.php index c3170ec..1f0204a 100644 --- a/lib/Controller/PreviewController.php +++ b/lib/Controller/PreviewController.php @@ -148,4 +148,4 @@ private function isScriptable(string $mime): bool { $type = strtolower(trim(explode(';', $mime, 2)[0])); return in_array($type, self::SCRIPTABLE_TYPES, true); } -} \ No newline at end of file +} From 2a5beaa5545d120afd6439f68142dfe2390d537d Mon Sep 17 00:00:00 2001 From: erseco Date: Mon, 6 Jul 2026 23:13:20 +0100 Subject: [PATCH 03/43] feat(viewer): add IframeSandbox service (secure tokens + published CSP) Single source of truth for the secure opaque-iframe policy shared with the other plugins: secure sandbox tokens ('allow-scripts allow-popups allow-forms', never allow-same-origin), the strict/compatible published CSP, the SVG/XML locked CSP, Permissions-Policy, provider whitelist, and the dev-only legacy escape hatch. --- lib/Service/IframeSandbox.php | 135 +++++++++++++++++++++++ tests/Unit/Service/IframeSandboxTest.php | 88 +++++++++++++++ 2 files changed, 223 insertions(+) create mode 100644 lib/Service/IframeSandbox.php create mode 100644 tests/Unit/Service/IframeSandboxTest.php diff --git a/lib/Service/IframeSandbox.php b/lib/Service/IframeSandbox.php new file mode 100644 index 0000000..2550b78 --- /dev/null +++ b/lib/Service/IframeSandbox.php @@ -0,0 +1,135 @@ +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; + } + + /** + * 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; + } +} diff --git a/tests/Unit/Service/IframeSandboxTest.php b/tests/Unit/Service/IframeSandboxTest.php new file mode 100644 index 0000000..bbe17d1 --- /dev/null +++ b/tests/Unit/Service/IframeSandboxTest.php @@ -0,0 +1,88 @@ + $legacyEnv); + } + + public function testDefaultsToSecureMode(): void { + self::assertSame(IframeSandbox::MODE_SECURE, $this->service()->resolveMode()); + } + + public function testLegacyModeOnlyViaEscapeHatch(): void { + self::assertSame(IframeSandbox::MODE_LEGACY, $this->service('1')->resolveMode()); + self::assertSame(IframeSandbox::MODE_LEGACY, $this->service('true')->resolveMode()); + self::assertSame(IframeSandbox::MODE_SECURE, $this->service('0')->resolveMode()); + self::assertSame(IframeSandbox::MODE_SECURE, $this->service('')->resolveMode()); + } + + 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); + } +} From b664e6acfc09bc5035815bf306a5930e7d1ed8a5 Mon Sep 17 00:00:00 2001 From: erseco Date: Mon, 6 Jul 2026 23:16:55 +0100 Subject: [PATCH 04/43] feat(viewer): add fileId-bound capability token service Stateless HMAC-SHA256 token binding a fileId + expiry under the instance secret, minted at view-open (authenticated) and verified by the cookieless opaque content route. Mirrors Moodle's tokenpluginfile capability model. Secret injected as a string to keep the service OCP-free and unit-testable. --- lib/Service/ContentTokenService.php | 91 +++++++++++++++++++ .../Unit/Service/ContentTokenServiceTest.php | 56 ++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 lib/Service/ContentTokenService.php create mode 100644 tests/Unit/Service/ContentTokenServiceTest.php diff --git a/lib/Service/ContentTokenService.php b/lib/Service/ContentTokenService.php new file mode 100644 index 0000000..ea6290c --- /dev/null +++ b/lib/Service/ContentTokenService.php @@ -0,0 +1,91 @@ +clock = $clock ?? static fn (): int => time(); + } + + /** + * Mint a capability token for $fileId valid for $ttlSeconds. + */ + public function mint(int $fileId, int $ttlSeconds = 3600): string { + $expiry = ($this->clock)() + $ttlSeconds; + $payload = $fileId . '.' . $expiry; + return $this->b64($payload) . '.' . $this->b64($this->sign($payload)); + } + + /** + * Verify a token: returns the file id when the signature is valid and the + * token has not expired, otherwise null. + */ + public function verify(string $token): ?int { + $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) !== 2) { + return null; + } + [$fileId, $expiry] = $segments; + if (!ctype_digit($fileId) || !ctype_digit($expiry)) { + return null; + } + if ((int)$expiry <= ($this->clock)()) { + return null; + } + return (int)$fileId; + } + + 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/tests/Unit/Service/ContentTokenServiceTest.php b/tests/Unit/Service/ContentTokenServiceTest.php new file mode 100644 index 0000000..3b0e1c7 --- /dev/null +++ b/tests/Unit/Service/ContentTokenServiceTest.php @@ -0,0 +1,56 @@ + $now); + } + + public function testRoundTrips(): void { + $svc = $this->service(); + self::assertSame(42, $svc->verify($svc->mint(42))); + } + + public function testTokenIsBoundToItsFileId(): void { + $svc = $this->service(); + $token = $svc->mint(42); + self::assertSame(42, $svc->verify($token)); + // A token minted for 42 can only ever resolve to 42. + self::assertNotSame(43, $svc->verify($token)); + } + + public function testTamperedPayloadRejected(): void { + $svc = $this->service(); + $token = $svc->mint(42); + $mangled = ($token[0] === 'A' ? 'B' : 'A') . substr($token, 1); + self::assertNull($svc->verify($mangled)); + } + + public function testExpiredTokenRejected(): void { + $token = $this->service(1000)->mint(42, 100); // expires at 1100 + self::assertNull($this->service(5000)->verify($token)); + } + + public function testStillValidBeforeExpiry(): void { + $token = $this->service(1000)->mint(42, 100); + self::assertSame(42, $this->service(1050)->verify($token)); + } + + public function testGarbageRejected(): void { + $svc = $this->service(); + self::assertNull($svc->verify('')); + self::assertNull($svc->verify('not-a-token')); + self::assertNull($svc->verify('a.b.c')); + self::assertNull($svc->verify('@@@.@@@')); + } +} From 60ca7af80da3564551ad7711e2e8be82f2cdff2a Mon Sep 17 00:00:00 2001 From: erseco Date: Mon, 6 Jul 2026 23:22:59 +0100 Subject: [PATCH 05/43] feat(viewer): opaque HTTP content-serving route (capability token + CSP + shim) ContentController serves published .elpx entries into an opaque-origin iframe over a cookieless capability URL: verify the fileId-bound token, resolve the file system-wide, read the entry, emit the strict published CSP on HTML (svgCsp on SVG/XML) + hardening headers, and inline the eXe embed shim into HTML documents. Adds EmbedShimInjector + PackageMimeService (OCP-free, tested) and refactors AssetController onto the shared MIME service (single source of truth). --- appinfo/routes.php | 7 + lib/AppInfo/Application.php | 10 ++ lib/Controller/AssetController.php | 34 +---- lib/Controller/ContentController.php | 121 ++++++++++++++++++ lib/Service/ElpxPackageService.php | 22 ++++ lib/Service/EmbedShimInjector.php | 40 ++++++ lib/Service/PackageMimeService.php | 69 ++++++++++ tests/Unit/Service/EmbedShimInjectorTest.php | 46 +++++++ tests/Unit/Service/PackageMimeServiceTest.php | 42 ++++++ 9 files changed, 360 insertions(+), 31 deletions(-) create mode 100644 lib/Controller/ContentController.php create mode 100644 lib/Service/EmbedShimInjector.php create mode 100644 lib/Service/PackageMimeService.php create mode 100644 tests/Unit/Service/EmbedShimInjectorTest.php create mode 100644 tests/Unit/Service/PackageMimeServiceTest.php diff --git a/appinfo/routes.php b/appinfo/routes.php index ded2661..f0f45d3 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -21,6 +21,13 @@ 'verb' => 'GET', 'requirements' => ['path' => '.+'], ], + [ + 'name' => 'content#serve', + 'url' => '/content/{token}/{path}', + 'verb' => 'GET', + 'requirements' => ['token' => '[A-Za-z0-9._~-]+', 'path' => '.+'], + 'defaults' => ['path' => 'index.html'], + ], [ 'name' => 'thumbnail#byFileId', 'url' => '/thumbnail/by-file-id/{fileId}', diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index d7a6019..02970d0 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -5,11 +5,14 @@ namespace OCA\ExeLearning\AppInfo; use OCA\ExeLearning\Preview\ElpxPreviewProvider; +use OCA\ExeLearning\Service\ContentTokenService; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\IConfig; use OCP\Util; +use Psr\Container\ContainerInterface; class Application extends App implements IBootstrap { public const APP_ID = 'exelearning'; @@ -57,6 +60,13 @@ public function register(IRegistrationContext $context): void { // the legacy `IPreview::registerProviderV2()` call in boot() only // covered some of those code paths. $context->registerPreviewProvider(ElpxPreviewProvider::class, ElpxPreviewProvider::MIME_REGEX); + + // The capability-token service needs the instance secret as a plain + // string (it is kept OCP-free for unit testing), so it cannot be + // auto-wired — provide a factory that reads it from IConfig. + $context->registerService(ContentTokenService::class, static function (ContainerInterface $c): ContentTokenService { + return new ContentTokenService((string)$c->get(IConfig::class)->getSystemValue('secret', '')); + }); } public function boot(IBootContext $context): void { diff --git a/lib/Controller/AssetController.php b/lib/Controller/AssetController.php index 2d6e22a..8ec1f8f 100644 --- a/lib/Controller/AssetController.php +++ b/lib/Controller/AssetController.php @@ -5,6 +5,7 @@ namespace OCA\ExeLearning\Controller; use OCA\ExeLearning\Service\ElpxPackageService; +use OCA\ExeLearning\Service\PackageMimeService; use OCA\ExeLearning\Service\ZipEntryService; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; @@ -27,37 +28,13 @@ * the current user can read the underlying file before serving any bytes. */ class AssetController extends Controller { - private const MIME_MAP = [ - 'html' => 'text/html; charset=utf-8', - 'htm' => 'text/html; 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 __construct( string $appName, IRequest $request, private readonly IUserSession $userSession, private readonly ElpxPackageService $packageService, private readonly ZipEntryService $zipEntries, + private readonly PackageMimeService $mime, ) { parent::__construct($appName, $request); } @@ -90,16 +67,11 @@ public function fetch(string $sessionId, string $path): DataDisplayResponse|Data return new DataResponse(['error' => 'Entry not found'], Http::STATUS_NOT_FOUND); } - $mime = $this->detectMime($entry); + $mime = $this->mime->detect($entry); $response = new DataDisplayResponse($contents, Http::STATUS_OK, ['Content-Type' => $mime]); $response->addHeader('X-Content-Type-Options', 'nosniff'); $response->addHeader('Content-Security-Policy', "default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; frame-ancestors 'self'"); $response->addHeader('Cache-Control', 'private, max-age=300'); return $response; } - - private function detectMime(string $entry): string { - $extension = strtolower(pathinfo($entry, PATHINFO_EXTENSION)); - return self::MIME_MAP[$extension] ?? 'application/octet-stream'; - } } diff --git a/lib/Controller/ContentController.php b/lib/Controller/ContentController.php new file mode 100644 index 0000000..c28271c --- /dev/null +++ b/lib/Controller/ContentController.php @@ -0,0 +1,121 @@ +tokens->verify($token); + if ($fileId === null) { + return $this->notFound(); + } + $entry = $this->zipEntries->normalizeEntry($path); + if ($entry === null) { + return $this->notFound(); + } + try { + $file = $this->packageService->getByIdForCapability($fileId); + } catch (NotFoundException | NotPermittedException) { + return $this->notFound(); + } + $bytes = $this->zipEntries->readEntry($file, $entry); + if ($bytes === null) { + return $this->notFound(); + } + + $mime = $this->mime->detect($entry); + if ($this->mime->isHtmlDocument($mime)) { + $shim = $this->shimSource(); + if ($shim !== null) { + $bytes = $this->shimInjector->injectIntoHead($bytes, $shim); + } + } + + $response = new DataDisplayResponse($bytes, Http::STATUS_OK, ['Content-Type' => $mime]); + $this->harden($response); + if ($this->mime->isHtmlDocument($mime)) { + $response->addHeader('Content-Security-Policy', $this->sandbox->contentSecurityPolicy()); + } elseif ($this->mime->isLockedXml($mime)) { + $response->addHeader('Content-Security-Policy', $this->sandbox->svgCsp()); + } + return $response; + } + + /** Inline shim source, or null when the mirror asset is not present. */ + private function shimSource(): ?string { + $path = __DIR__ . '/../../js/embed/exe_embed_shim.js'; + if (!is_file($path)) { + return null; + } + $source = file_get_contents($path); + return $source === false ? null : $source; + } + + /** 404 with the mandatory hardening headers (present on every response). */ + private function notFound(): DataDisplayResponse { + $response = new DataDisplayResponse('Not Found', Http::STATUS_NOT_FOUND, ['Content-Type' => 'text/plain; charset=utf-8']); + $this->harden($response); + return $response; + } + + private function harden(DataDisplayResponse $response): void { + $response->addHeader('X-Content-Type-Options', 'nosniff'); + $response->addHeader('Referrer-Policy', 'no-referrer'); + $response->addHeader('Cache-Control', 'no-store'); + $response->addHeader('Permissions-Policy', $this->sandbox->permissionsPolicy()); + // Cookieless capability origin — safe to allow any reader, never with credentials. + $response->addHeader('Access-Control-Allow-Origin', '*'); + } +} diff --git a/lib/Service/ElpxPackageService.php b/lib/Service/ElpxPackageService.php index b13f743..01d227a 100644 --- a/lib/Service/ElpxPackageService.php +++ b/lib/Service/ElpxPackageService.php @@ -51,6 +51,28 @@ public function getForUserByPath(string $userId, string $path): File { return $node; } + /** + * Resolve a file by id WITHOUT a user session, for the cookieless opaque + * content route. The {@see \OCA\ExeLearning\Service\ContentTokenService} + * capability token — minted at view-open where the user's read permission + * WAS checked — is the authorization; here we only re-assert the node still + * exists, is a regular file, and is within the size limit. + * + * @throws NotFoundException when the file id no longer resolves to a file + * @throws NotPermittedException when it is not a regular file / too large + */ + public function getByIdForCapability(int $fileId): File { + $nodes = $this->rootFolder->getById($fileId); + $node = $nodes[0] ?? null; + if (!$node instanceof File) { + throw new NotFoundException('File not found'); + } + if (!$this->permissions->isWithinSizeLimit($node)) { + throw new NotPermittedException('Package too large'); + } + return $node; + } + /** * @throws NotPermittedException */ diff --git a/lib/Service/EmbedShimInjector.php b/lib/Service/EmbedShimInjector.php new file mode 100644 index 0000000..372f0bc --- /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/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/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')); + } +} From e48c2539c68196e6df38723ec17594309dad3c7d Mon Sep 17 00:00:00 2001 From: erseco Date: Mon, 6 Jul 2026 23:24:14 +0100 Subject: [PATCH 06/43] feat(viewer): mint content capability token at view-open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a package resolves and secure mode is active, provide a fileId-bound capability token (contentToken) + the secureIframe flag as initial state, so the Vue viewer can load the package from the opaque /content/{token}/… route. The read permission was already checked by getForUser*; legacy mode leaves the token null and keeps the Service-Worker path. --- lib/Controller/ViewController.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/Controller/ViewController.php b/lib/Controller/ViewController.php index 87ac8a1..fcdbced 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,17 @@ 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()) : null, + ); } + $this->initialState->provideInitialState('secureIframe', $secureIframe); $this->initialState->provideInitialState( 'editorAvailable', is_file(__DIR__ . '/../../js/editor/index.html'), From 8eda8a76ad17d0f057c180a155f5eb1fc61830be Mon Sep 17 00:00:00 2001 From: erseco Date: Mon, 6 Jul 2026 23:27:53 +0100 Subject: [PATCH 07/43] feat(viewer): opaque iframe (drop allow-same-origin) + content-URL builder iframe-renderer now defaults to the secure opaque sandbox ('allow-scripts allow-popups allow-forms', no allow-same-origin) and adds createContentIframe for the /content/{token} route; the legacy same-origin Service-Worker path (createPackageIframe) keeps allow-same-origin + link rewiring behind opaque=false. paths.ts gains buildContentUrl (token-addressed). --- src/elpx/iframe-renderer.ts | 97 ++++++++++++++++++++++---------- src/elpx/paths.ts | 23 ++++++++ tests/js/iframe-renderer.test.ts | 43 ++++++++++---- tests/js/paths.test.ts | 21 +++++++ 4 files changed, 143 insertions(+), 41 deletions(-) diff --git a/src/elpx/iframe-renderer.ts b/src/elpx/iframe-renderer.ts index 6b18f6b..d0c6a01 100644 --- a/src/elpx/iframe-renderer.ts +++ b/src/elpx/iframe-renderer.ts @@ -1,15 +1,29 @@ /** - * Builds the sandboxed iframe that displays a session. The iframe is the only - * point where package HTML executes, and the sandbox flags are tuned to the - * minimum that still lets eXeLearning's iDevices run. + * Builds the sandboxed iframe that displays a package. * - * External links inside the iframe are forced to open in a new tab so the - * parent Nextcloud window never navigates away from the Viewer. + * The default (secure) path serves the package from the opaque `/content` + * route: the iframe drops `allow-same-origin`, so the document runs in a + * browser-enforced **opaque origin** and cannot reach the Nextcloud DOM, + * cookies or storage. External media is handled by the eXe embed relay running + * in the parent (see relay-host.ts), not by reaching into the iframe document. + * + * The legacy path (Service-Worker `/runtime` route, only via the + * EXELEARNING_UNSAFE_LEGACY_IFRAME escape hatch) keeps `allow-same-origin` — + * the SW can only control a same-origin document — and rewires external links + * by touching the same-origin iframe document. */ -import { buildRuntimeUrl } from './paths' +import { buildContentUrl, buildRuntimeUrl } from './paths' + +/** Opaque, secure sandbox: no allow-same-origin (that absence is the isolation). */ +const SECURE_SANDBOX_FLAGS = [ + 'allow-scripts', + 'allow-popups', + 'allow-forms', +] as const -const SANDBOX_FLAGS = [ +/** Legacy same-origin sandbox for the Service-Worker path only. */ +const LEGACY_SANDBOX_FLAGS = [ 'allow-scripts', 'allow-same-origin', 'allow-forms', @@ -57,55 +71,76 @@ export interface IframeOptions { title: string } +export interface ContentIframeOptions { + contentBase: string + token: string + indexEntry: string + title: string +} + /** - * 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. + * Builds a sandboxed iframe pointed at an already-resolved `src`. * @param src Fully-resolved URL the iframe should load. * @param title Accessible title for the iframe. + * @param opaque When true (default) use the secure opaque sandbox and do not + * touch the iframe document; when false use the legacy same-origin sandbox + * (Service-Worker path) and rewire external links. */ -export function buildSandboxedIframe(src: string, title: string): HTMLIFrameElement { +export function buildSandboxedIframe(src: string, title: string, opaque = true): HTMLIFrameElement { const iframe = document.createElement('iframe') iframe.className = 'exelearning-viewer__iframe' iframe.title = title - iframe.setAttribute('sandbox', SANDBOX_FLAGS.join(' ')) + iframe.setAttribute('sandbox', (opaque ? SECURE_SANDBOX_FLAGS : LEGACY_SANDBOX_FLAGS).join(' ')) iframe.setAttribute('allow', IFRAME_ALLOW) iframe.setAttribute('referrerpolicy', 'no-referrer') - // `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. + // `src` is always the package *index* entry. 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 content/SW route matches requests + // on the pathname only, so the extra query is harmless. iframe.src = withTeacherMode(src) - iframe.addEventListener('load', () => { - try { - rewireExternalLinks(iframe) - } catch { - // Cross-origin error: we lose the same-origin invariant when the - // SW is bypassed. We swallow the error rather than break the view. - } - }) + if (!opaque) { + iframe.addEventListener('load', () => { + try { + rewireExternalLinks(iframe) + } catch { + // Same-origin access can still fail; swallow rather than break the view. + } + }) + } 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. + * Builds the opaque-origin iframe that renders a published package over the + * `/content/{token}/…` capability route. This is the default (secure) path. + * @param options Content base URL, capability token, index entry and title. + */ +export function createContentIframe(options: ContentIframeOptions): HTMLIFrameElement { + return buildSandboxedIframe( + buildContentUrl(options.contentBase, options.token, options.indexEntry), + options.title, + true, + ) +} + +/** + * Builds the legacy same-origin iframe that renders a registered package + * session over the Service-Worker `/runtime` route. Only used when the + * EXELEARNING_UNSAFE_LEGACY_IFRAME escape hatch is on. * @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, + false, ) } /** * Forces every external (`scheme:` or `//host`) link inside the iframe to - * open in a new tab with `noopener noreferrer`, so the Viewer modal never - * navigates away from Nextcloud. + * open in a new tab with `noopener noreferrer`, so the Viewer never navigates + * away from Nextcloud. Only usable on the same-origin (legacy) path. * @param iframe Iframe whose document should be augmented. */ function rewireExternalLinks(iframe: HTMLIFrameElement): void { diff --git a/src/elpx/paths.ts b/src/elpx/paths.ts index fd6efe4..f97c8d7 100644 --- a/src/elpx/paths.ts +++ b/src/elpx/paths.ts @@ -7,6 +7,7 @@ export const RUNTIME_PREFIX = '/apps/exelearning/runtime' export const ASSET_PREFIX = '/apps/exelearning/asset' +export const CONTENT_PREFIX = '/apps/exelearning/content' const PROTOCOL_LIKE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/ @@ -131,6 +132,28 @@ export function buildAssetUrl(base: string, fileId: number, entry: string): stri .join('/')}` } +/** + * Builds an iframe-loadable URL for an entry served by the **opaque** content + * route ({@link CONTENT_PREFIX}). Unlike {@link buildAssetUrl}, the file is + * addressed by a short-lived capability `token` (minted server-side, bound to + * the file id) rather than the raw file id, because the opaque origin sends no + * session cookie — the token is the only credential. + * @param base Content base URL (typically `generateUrl(CONTENT_PREFIX)`). + * @param token Capability token from initial state (`contentToken`). + * @param entry Normalised entry path inside the package. + */ +export function buildContentUrl(base: string, token: string, entry: string): string { + const normalized = normalizeEntryPath(entry) + if (normalized === null) { + throw new Error(`Refusing to build content URL for unsafe entry: ${entry}`) + } + const cleanBase = base.replace(/\/+$/, '') + return `${cleanBase}/${encodeURIComponent(token)}/${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. diff --git a/tests/js/iframe-renderer.test.ts b/tests/js/iframe-renderer.test.ts index b829a0f..f7cf214 100644 --- a/tests/js/iframe-renderer.test.ts +++ b/tests/js/iframe-renderer.test.ts @@ -1,36 +1,58 @@ import { describe, expect, it } from 'vitest' import { buildSandboxedIframe, + createContentIframe, createPackageIframe, } from '../../src/elpx/iframe-renderer' -import { RUNTIME_PREFIX } from '../../src/elpx/paths' +import { CONTENT_PREFIX, 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') + const iframe = buildSandboxedIframe('/apps/exelearning/content/tok/index.html', 'pkg') expect(iframe.src).toContain('exe-teacher=1') - expect(iframe.src).toContain('/apps/exelearning/asset/42/index.html?exe-teacher=1') + expect(iframe.src).toContain('/apps/exelearning/content/tok/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') + const iframe = buildSandboxedIframe('/apps/exelearning/content/tok/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') + const iframe = buildSandboxedIframe('/apps/exelearning/content/tok/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') + it('defaults to the secure opaque sandbox with NO allow-same-origin', () => { + const iframe = buildSandboxedIframe('/apps/exelearning/content/tok/index.html', 'My package') + const sandbox = iframe.getAttribute('sandbox') ?? '' + expect(sandbox).toBe('allow-scripts allow-popups allow-forms') + expect(sandbox).not.toContain('allow-same-origin') + expect(sandbox).not.toContain('allow-popups-to-escape-sandbox') expect(iframe.title).toBe('My package') }) + + it('uses the legacy same-origin sandbox when opaque=false', () => { + const iframe = buildSandboxedIframe('/apps/exelearning/runtime/s/index.html', 'pkg', false) + expect(iframe.getAttribute('sandbox')).toContain('allow-same-origin') + }) +}) + +describe('createContentIframe', () => { + it('builds an opaque /content/{token} index src with the teacher-mode selector', () => { + const iframe = createContentIframe({ + contentBase: CONTENT_PREFIX, + token: 'cap-token-123', + indexEntry: 'index.html', + title: 'pkg', + }) + expect(iframe.src).toContain(`${CONTENT_PREFIX}/cap-token-123/index.html?exe-teacher=1`) + expect(iframe.getAttribute('sandbox')).not.toContain('allow-same-origin') + }) }) -describe('createPackageIframe', () => { - it('builds a runtime index src that exposes the teacher-mode selector', () => { +describe('createPackageIframe (legacy Service Worker path)', () => { + it('builds a same-origin runtime index src', () => { const iframe = createPackageIframe({ runtimeBase: RUNTIME_PREFIX, sessionId: 'session-1', @@ -38,5 +60,6 @@ describe('createPackageIframe', () => { title: 'pkg', }) expect(iframe.src).toContain(`${RUNTIME_PREFIX}/session-1/index.html?exe-teacher=1`) + expect(iframe.getAttribute('sandbox')).toContain('allow-same-origin') }) }) diff --git a/tests/js/paths.test.ts b/tests/js/paths.test.ts index 709f439..d7effbf 100644 --- a/tests/js/paths.test.ts +++ b/tests/js/paths.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { buildAssetUrl, + buildContentUrl, buildRuntimeUrl, isExternalUrl, normalizeEntryPath, @@ -118,3 +119,23 @@ describe('buildAssetUrl', () => { expect(() => buildAssetUrl(base, 1, '../etc/passwd')).toThrow() }) }) + +describe('buildContentUrl', () => { + const base = '/apps/exelearning/content' + + it('builds an opaque content URL from a capability token and entry', () => { + expect(buildContentUrl(base, 'cap-token', 'html/page.html')).toBe( + '/apps/exelearning/content/cap-token/html/page.html', + ) + }) + + it('encodes the token and each entry segment', () => { + expect(buildContentUrl(base, 'a.b_c-d', 'content/Página 1.html')).toBe( + '/apps/exelearning/content/a.b_c-d/content/P%C3%A1gina%201.html', + ) + }) + + it('refuses unsafe entries', () => { + expect(() => buildContentUrl(base, 'tok', '../etc/passwd')).toThrow() + }) +}) From 53ec20a5c90b5a681b127e10ba4cd27a9d423e76 Mon Sep 17 00:00:00 2001 From: erseco Date: Mon, 6 Jul 2026 23:43:49 +0100 Subject: [PATCH 08/43] feat(viewer): external-media relay + media host in the opaque viewer Mirror the eXe-core embed/media bridge (exe_embed_shim/relay + exe_media_policy/host) into src/embed/ and drive them from the Viewer: the opaque content iframe's shim promotes cross-origin/PDF players to placeholders, and relay-host.ts overlays real players (Channel A) + hosts the interactive-video bridge (Channel B), pinging on load and clearing/reflowing overlays on hide/resize. ElpxViewer now defaults to the opaque /content/{token} path when a token is present, keeping the Service-Worker path only under the legacy escape hatch. Mirror JS excluded from eslint/biome (kept byte-synced with core). --- .eslintrc.cjs | 4 + biome.json | 3 +- lib/Controller/ContentController.php | 8 +- src/embed/exe_embed_relay.js | 571 +++++++++++++++++++++++++++ src/embed/exe_embed_shim.js | 316 +++++++++++++++ src/embed/exe_media_host.js | 533 +++++++++++++++++++++++++ src/embed/exe_media_policy.js | 258 ++++++++++++ src/embed/relay-host.ts | 87 ++++ src/types/shims.d.ts | 8 + src/viewer/ElpxViewer.vue | 75 +++- tests/js/relay-host.test.ts | 67 ++++ 11 files changed, 1923 insertions(+), 7 deletions(-) create mode 100644 src/embed/exe_embed_relay.js create mode 100644 src/embed/exe_embed_shim.js create mode 100644 src/embed/exe_media_host.js create mode 100644 src/embed/exe_media_policy.js create mode 100644 src/embed/relay-host.ts create mode 100644 tests/js/relay-host.test.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index c41d06b..39a34a3 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -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 diff --git a/biome.json b/biome.json index 48e4ede..2276ae5 100644 --- a/biome.json +++ b/biome.json @@ -16,7 +16,8 @@ "!**/coverage/**", "!**/js/**", "!**/exelearning/**", - "!**/*.vue" + "!**/*.vue", + "!**/src/embed/exe_*.js" ] }, "formatter": { diff --git a/lib/Controller/ContentController.php b/lib/Controller/ContentController.php index c28271c..56ba2f2 100644 --- a/lib/Controller/ContentController.php +++ b/lib/Controller/ContentController.php @@ -93,9 +93,13 @@ public function serve(string $token, string $path = 'index.html'): DataDisplayRe return $response; } - /** Inline shim source, or null when the mirror asset is not present. */ + /** + * Inline shim source, or null when the mirror asset is not present. Read + * from src/embed/ at runtime, the same way {@see SwController} reads + * src/sw/exelearning-sw.js — the app ships its src/ tree. + */ private function shimSource(): ?string { - $path = __DIR__ . '/../../js/embed/exe_embed_shim.js'; + $path = __DIR__ . '/../../src/embed/exe_embed_shim.js'; if (!is_file($path)) { return null; } diff --git a/src/embed/exe_embed_relay.js b/src/embed/exe_embed_relay.js new file mode 100644 index 0000000..b180381 --- /dev/null +++ b/src/embed/exe_embed_relay.js @@ -0,0 +1,571 @@ +/** + * Parent-side external-embed relay for the opaque-origin preview / secure package mode. + * + * Companion to exe_embed_shim.js (runs INSIDE the opaque iframe). In opaque mode the + * document is sandboxed, so cross-origin players (YouTube, Vimeo) and PDFs render blank. + * The shim replaces each candidate iframe with a placeholder and postMessages its + * geometry here; this relay (the trusted half, in the editor / host page) validates each + * URL and overlays the real player inline over the placeholder — automatically, with no + * click, tracking scroll/resize. + * + * Trust model: the promoted player is rendered cross-origin and SANDBOXED, so the + * same-origin policy isolates it from this host page (it cannot read the DOM, cookies, + * session or storage). Two modes: + * - 'open' (default): promote any iframe whose src is https AND cross-origin to the host + * (rejecting same-origin, sub/superdomains of the host, IP/loopback/local hosts and + * userinfo). No host list. The host is irrelevant to escape; the residual is + * phishing/tracking, bounded to the content's own box (the overlay is clamped). + * - 'strict': only a maintained host allowlist with per-provider canonical-URL + * reconstruction, for high-security deployments. + * "Any https .pdf" is always allowed (same-origin only for the package's own files). + * + * Messages are authenticated by window identity (event.source === a known CONTENT + * iframe, never a promoted player); the opaque origin has no useful event.origin. + * + * Exposed two ways from a single body: window.exeEmbedRelay (browser bootstrap) and + * module.exports (tests). + * + * CANONICAL SOURCE for the eXeLearning embedder family lives here in eXeLearning core + * (public/app/common/exe_embed_bridge/exe_embed_relay.js). The host plugins + * (mod_exelearning, wp-exelearning, omeka-s-exelearning, procomun) mirror this logic + * (only the export wrapper differs). Keep them in sync; changes flow from core outward. + */ +(function () { + 'use strict'; + + /** + * Build a host lookup map from a whitelist array (lowercased). Used by 'strict' mode. + * + * @param {string[]} list + * @returns {Object} + */ + function buildWhitelist(list) { + var map = {}; + (list || []).forEach(function (host) { + map[String(host).toLowerCase()] = true; + }); + return map; + } + + /** + * Directory portion of the content iframe src (everything up to the last '/'). + * + * @param {string} src + * @returns {string} + */ + function contentDir(src) { + try { + return new URL(src, window.location.href).href.replace(/[^/]*([?#].*)?$/, ''); + } catch (e) { + return ''; + } + } + + /** + * Long hex token shared by the content URL and its assets (null when there is + * none, e.g. content URLs that use numeric ids). + * + * @param {string} src + * @returns {?string} + */ + function packageId(src) { + var match = String(src).match(/[a-f0-9]{12,}/i); + return match ? match[0] : null; + } + + /** + * Whether a same-origin URL is one of this package's own extracted files: under + * the content's own directory, or carrying the package hash as a path segment. + * + * @param {URL} url + * @param {string} contentSrc + * @returns {boolean} + */ + function isSameOriginPackageFile(url, contentSrc) { + var dir = contentDir(contentSrc); + if (dir && url.href.indexOf(dir) === 0) { + return true; + } + var id = packageId(contentSrc); + return !!(id && url.pathname.indexOf('/' + id + '/') !== -1); + } + + /** + * Whether a host is an IP literal (v4/v6) or a loopback/local name. Such hosts are + * cross-origin to the LMS yet target the machine/internal network, so they are + * rejected even though SOP would isolate them. + * + * @param {string} host Lowercased URL.hostname. + * @returns {boolean} + */ + function isIpOrLocalHost(host) { + if (!host) { return true; } + if (host === 'localhost' || /\.localhost$/.test(host) || /\.local$/.test(host)) { return true; } + if (host.charAt(0) === '[' || host.indexOf(':') !== -1) { return true; } // IPv6 (bracketed). + if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) { return true; } // Any IPv4 literal. + return false; + } + + /** + * Lowercase a hostname and strip a single trailing dot. 'lms.example.org.' (the + * FQDN-root form) resolves to the same vhost as 'lms.example.org' but compares + * unequal as a raw string, so without this it would slip past the same-origin / + * related-to-LMS gate below and be promoted as a cross-origin player. + * + * @param {string} host + * @returns {string} + */ + function normalizeHost(host) { + return (host || '').toLowerCase().replace(/\.$/, ''); + } + + /** + * Whether a host equals, is a subdomain of, or is a superdomain of the LMS host + * (dotted boundary so 'evil-lms.example' does not match 'lms.example'). Such hosts + * may share the LMS cookies, so they are rejected. Both sides are normalised so the + * trailing-dot FQDN-root form cannot evade the comparison. + * + * @param {string} host + * @param {string} lmsHost + * @returns {boolean} + */ + function isRelatedToLms(host, lmsHost) { + host = normalizeHost(host); + lmsHost = normalizeHost(lmsHost); + if (!lmsHost) { return false; } + return host === lmsHost || host.endsWith('.' + lmsHost) || lmsHost.endsWith('.' + host); + } + + /** + * The structural invariant: an https URL cross-origin to the LMS and not pointing at + * a sub/superdomain, an IP/loopback/local host, or carrying userinfo. This is the + * only attacker-influenced gate in 'open' mode, and it is what makes the sandboxed + * player's allow-same-origin safe (the embed keeps ITS OWN origin, isolated by SOP). + * + * @param {URL} url + * @returns {boolean} + */ + function isCrossOriginHttps(url) { + if (url.protocol !== 'https:') { return false; } + if (url.username || url.password) { return false; } + if (url.origin === window.location.origin) { return false; } + var host = normalizeHost(url.hostname); + if (isIpOrLocalHost(host)) { return false; } + var lmshost = (window.location && window.location.hostname) ? window.location.hostname : ''; + if (isRelatedToLms(host, lmshost)) { return false; } + return true; + } + + // Provider templates for the id-only channel (DEC-0067): the parent rebuilds the canonical + // embed URL from {provider, objectId} reported by the shim, re-checking the object id + // against a strict per-provider regex so it cannot carry a path/query/fragment and escape + // the template (e.g. '../../x' or 'a/b?c'). The reconstructed URL still runs through + // validate() (structural invariant / strict whitelist), so this narrows the surface for + // recognised providers; it is not, by itself, the trust gate. + var PROVIDER_TEMPLATES = { + youtube: { re: /^[A-Za-z0-9_-]{6,}$/, build: function (id) { return 'https://www.youtube-nocookie.com/embed/' + id; } }, + vimeo: { re: /^[0-9]+$/, build: function (id) { return 'https://player.vimeo.com/video/' + id; } }, + dailymotion: { re: /^[A-Za-z0-9]{5,}$/, build: function (id) { return 'https://www.dailymotion.com/embed/video/' + id; } }, + 'mediateca-madrid': { re: /^[A-Za-z0-9]{8,}$/, build: function (id) { return 'https://mediateca.educa.madrid.org/video/' + id + '/fs'; } } + }; + + /** + * Rebuild the canonical embed URL for a recognised provider from its object id, or null + * if the provider is unknown or the id is not the exact shape the template expects. + * + * @param {string} provider + * @param {string} objectId + * @returns {?string} + */ + function reconstructProvider(provider, objectId) { + var t = PROVIDER_TEMPLATES[provider]; + if (!t || typeof objectId !== 'string' || !t.re.test(objectId)) { + return null; + } + return t.build(objectId); + } + + /** + * Validate an embed URL. Returns {url, kind ('video'|'pdf'), sameorigin?} or null. + * + * @param {string} raw The reported (absolute) embed URL. + * @param {string} contentSrc The src of the content iframe that reported it. + * @param {Object} opts {strict: boolean, whitelist: Object}. + * @returns {?Object} + */ + function validate(raw, contentSrc, opts) { + opts = opts || {}; + var url; + try { + // Parse as an ABSOLUTE URL (the shim always reports absolute). No base: + // a relative/scheme-relative value would otherwise inherit the LMS origin + // and pass as same-origin -- here it throws and is rejected instead. + url = new URL(raw); + } catch (e) { + return null; + } + if (url.username || url.password) { + return null; // Reject userinfo, e.g. https://evil.com@youtube.com/. + } + var host = url.hostname.toLowerCase(); + + // PDFs: any cross-origin https .pdf, or a same-origin file that belongs to this + // package (served as application/pdf + nosniff, never executable HTML). + if (/\.pdf$/i.test(url.pathname)) { + if (url.origin === window.location.origin) { + return isSameOriginPackageFile(url, contentSrc) ? { url: url.href, kind: 'pdf', sameorigin: true } : null; + } + return isCrossOriginHttps(url) ? { url: url.href, kind: 'pdf' } : null; + } + + // Strict mode: maintained host allowlist + per-provider canonical reconstruction. + if (opts.strict) { + var whitelist = opts.whitelist || {}; + if (whitelist[host] && url.protocol === 'https:') { + var m; + if (host.indexOf('youtube') !== -1) { + m = url.pathname.match(/^\/embed\/([A-Za-z0-9_-]{6,})$/); + return m ? { url: 'https://www.youtube-nocookie.com/embed/' + m[1], kind: 'video' } : null; + } + if (host.indexOf('vimeo') !== -1) { + m = url.pathname.match(/^\/video\/([0-9]+)$/); + return m ? { url: 'https://player.vimeo.com/video/' + m[1], kind: 'video' } : null; + } + if (host.indexOf('dailymotion') !== -1) { + m = url.pathname.match(/^\/embed\/video\/([A-Za-z0-9]{5,})$/); + return m ? { url: 'https://www.dailymotion.com/embed/video/' + m[1], kind: 'video' } : null; + } + if (host === 'mediateca.educa.madrid.org') { + m = url.pathname.match(/^\/video\/([A-Za-z0-9]{8,})(?:\/fs)?$/); + return m ? { url: 'https://mediateca.educa.madrid.org/video/' + m[1] + '/fs', kind: 'video' } : null; + } + } + return null; + } + + // Open mode (default): any cross-origin https iframe is a video embed. + return isCrossOriginHttps(url) ? { url: url.href, kind: 'video' } : null; + } + + /** + * Create the player iframe for a validated embed. The video player gets allow-same-origin + * (so the cross-origin provider keeps its own origin and renders) while omitting + * allow-top-navigation/allow-modals, so a hostile embed cannot redirect the LMS tab or + * spam dialogs. A same-origin package PDF is served as application/pdf + nosniff (never + * executable HTML) and is left unsandboxed so the browser's built-in viewer renders it; a + * CROSS-ORIGIN PDF URL comes from the untrusted package, so it is sandboxed WITHOUT + * allow-top-navigation (a server can serve scripted HTML at a .pdf path, which unsandboxed + * could top-navigate the parent tab to a phishing page). + * + * @param {Object} result {url, kind, sameorigin?} from validate(). + * @returns {HTMLIFrameElement} + */ + function makePlayer(result) { + var frame = document.createElement('iframe'); + frame.style.cssText = 'position:absolute;border:0;pointer-events:auto;'; + // Mark as a player so it is never mistaken for a content source (message auth). + frame.setAttribute('data-exe-embed-player', '1'); + if (result.kind === 'video') { + frame.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-popups allow-forms allow-presentation'); + frame.setAttribute('allow', 'autoplay; encrypted-media; fullscreen; picture-in-picture; clipboard-write'); + frame.setAttribute('allowfullscreen', ''); + frame.setAttribute('referrerpolicy', 'strict-origin-when-cross-origin'); + } else if (result.sameorigin) { + // Same-origin PDF that belongs to THIS package: served application/pdf + nosniff + // (never executable HTML, so it cannot script or navigate), left unsandboxed so the + // browser's built-in viewer renders it (it shows the broken-document icon inside a + // sandbox). The load guard still removes it if it redirects to the LMS origin. + frame.setAttribute('allow', 'fullscreen'); + frame.setAttribute('referrerpolicy', 'no-referrer'); + } else { + // Cross-origin PDF whose URL is controlled by the untrusted package. A server can + // serve scripted HTML at a ".pdf" path; unsandboxed, that frame could top-navigate + // the Moodle tab to a phishing page on a click (a package must never change the + // parent URL). Sandbox it WITHOUT allow-top-navigation/allow-scripts; allow-same- + // origin keeps the provider's own origin (SOP-isolated from the LMS). Trade-off: a + // genuine cross-origin PDF may render the broken-document icon under the sandbox -- + // accepted, since local package PDFs (the common case) take the branch above and + // blocking the tab-redirect vector matters more than inlining a remote PDF. + frame.setAttribute('sandbox', 'allow-same-origin'); + frame.setAttribute('allow', 'fullscreen'); + frame.setAttribute('referrerpolicy', 'no-referrer'); + } + frame.src = result.url; + // Tag with the URL it renders so sync() can detect when a reused embed id (the + // shim restarts its counter per page) now points at a different URL. + frame.setAttribute('data-exe-embed-src', result.url); + return frame; + } + + /** + * Create a relay instance. + * + * @param {Object} config {mode: 'open'|'strict', whitelist: string[]} + * @returns {Object} + */ + function createRelay(config) { + config = config || {}; + var strict = config.mode === 'strict'; + var whitelist = buildWhitelist(config.whitelist); + var overlays = []; + + function findOverlay(iframe) { + for (var i = 0; i < overlays.length; i++) { + if (overlays[i].iframe === iframe) { + return overlays[i]; + } + } + return null; + } + + // Resolve the CONTENT iframe a message came from. Promoted players are excluded + // (data-exe-embed-player): a sandboxed player with allow-same-origin could + // otherwise postMessage a forged 'sync' and impersonate a content source. + function frameForSource(source) { + var frames = document.getElementsByTagName('iframe'); + for (var i = 0; i < frames.length; i++) { + if (frames[i].getAttribute('data-exe-embed-player')) { + continue; + } + if (frames[i].contentWindow === source) { + return frames[i]; + } + } + return null; + } + + function overlayFor(iframe) { + var entry = findOverlay(iframe); + if (entry) { + return entry; + } + var el = document.createElement('div'); + el.className = 'exe-embed-overlay'; + el.style.cssText = 'position:absolute;overflow:hidden;pointer-events:none;z-index:2147483646;'; + document.body.appendChild(el); + entry = { iframe: iframe, el: el, players: {} }; + overlays.push(entry); + return entry; + } + + function positionOverlay(entry, rect) { + rect = rect || entry.iframe.getBoundingClientRect(); + var scrollX = window.pageXOffset || document.documentElement.scrollLeft || 0; + var scrollY = window.pageYOffset || document.documentElement.scrollTop || 0; + entry.el.style.left = (rect.left + scrollX) + 'px'; + entry.el.style.top = (rect.top + scrollY) + 'px'; + entry.el.style.width = rect.width + 'px'; + entry.el.style.height = rect.height + 'px'; + } + + // D1: if a promoted embed lands SAME-ORIGIN to the LMS (e.g. a cross-origin URL + // that 30x-redirects to this origin), with allow-same-origin it would become + // scriptable against this page -> remove it. A genuine cross-origin player throws + // on contentWindow.document (expected, kept). Not armed for same-origin package + // PDFs (intentionally same-origin, served as application/pdf). + function armSameOriginGuard(entry, id, player) { + player.addEventListener('load', function () { + try { + if (player.contentWindow && player.contentWindow.document) { + if (player.parentNode) { player.parentNode.removeChild(player); } + if (entry.players[id] === player) { delete entry.players[id]; } + } + } catch (e) { /* cross-origin: expected, keep the player */ } + }); + } + + function sync(entry, embeds, contentSrc) { + // The content iframe's box is invariant across this sync pass (the loop only + // mutates the overlay and its players), so read it once and reuse it for the + // overlay position and every player clamp -- avoids a forced reflow per embed. + var rect = entry.iframe.getBoundingClientRect(); + positionOverlay(entry, rect); + var seen = {}; + embeds.forEach(function (embed) { + if (!embed || typeof embed.id !== 'string') { + return; + } + if (!isFinite(embed.x) || !isFinite(embed.y) || !isFinite(embed.w) || !isFinite(embed.h)) { + return; + } + // id-only channel (DEC-0067): for recognised providers the shim reports + // {provider, objectId} and the parent rebuilds the canonical URL from a + // fixed template (the author URL never crosses for these). Unknown embeds + // keep the URL path. Either way validate() runs the structural invariant. + var raw = embed.url; + if (embed.provider && embed.objectId) { + raw = reconstructProvider(embed.provider, embed.objectId); + if (!raw) { + return; + } + } + var result = validate(raw, contentSrc, { strict: strict, whitelist: whitelist }); + if (!result) { + return; + } + seen[embed.id] = true; + var player = entry.players[embed.id]; + // After the content navigates, the shim reuses ids (exe-embed-1, ...) for + // the new page's embeds. If this id now renders a different URL, drop the + // stale player so the previous page's video does not linger here. + if (player && player.getAttribute('data-exe-embed-src') !== result.url) { + player.parentNode.removeChild(player); + delete entry.players[embed.id]; + player = null; + } + if (!player) { + player = makePlayer(result); + entry.el.appendChild(player); + entry.players[embed.id] = player; + if (!result.sameorigin) { + armSameOriginGuard(entry, embed.id, player); + } + } + // Defence in depth against clickjacking: the overlay is clamped to the + // content iframe's box and clips with overflow:hidden, so a player can + // never cover host UI outside the iframe. Cap the player size to the + // overlay too (the content reports geometry, the parent owns rendering). + // Reuses the iframe rect read once at the top of this pass. + player.style.left = embed.x + 'px'; + player.style.top = embed.y + 'px'; + player.style.width = Math.min(embed.w, rect.width) + 'px'; + player.style.height = Math.min(embed.h, rect.height) + 'px'; + }); + Object.keys(entry.players).forEach(function (id) { + if (!seen[id]) { + entry.players[id].parentNode.removeChild(entry.players[id]); + delete entry.players[id]; + } + }); + } + + function onMessage(event) { + var data = event.data; + if (!data || data.type !== 'exe-embed' || data.action !== 'sync' || !Array.isArray(data.embeds)) { + return; + } + var iframe = frameForSource(event.source); + if (!iframe) { + return; + } + sync(overlayFor(iframe), data.embeds, iframe.src); + } + + // Browser-only glue below (window listeners, reflow on scroll/resize, pinging + // the content iframes). Exercised by the Playwright/Firefox e2e + // (tests/e2e/embed.spec.cjs), not the happy-dom unit tests. + /* v8 ignore start */ + function pingAll() { + var frames = document.getElementsByTagName('iframe'); + for (var i = 0; i < frames.length; i++) { + if (frames[i].getAttribute('data-exe-embed-player')) { + continue; + } + try { + frames[i].contentWindow.postMessage({ type: 'exe-embed', action: 'request' }, '*'); + } catch (e) { + // Cross-origin player iframes reject this; harmless. + } + } + } + + var scheduled = false; + function scheduleReflow() { + if (scheduled) { + return; + } + scheduled = true; + window.requestAnimationFrame(function () { + scheduled = false; + for (var i = 0; i < overlays.length; i++) { + positionOverlay(overlays[i]); + } + }); + } + /* v8 ignore stop */ + + // Tear down every overlay and its players. Used when the host (e.g. the editor + // preview panel) hides or closes the content iframe: the overlay lives on the + // host's own body, so without this it would linger over the host UI. A later + // sync (after the iframe reloads) rebuilds the overlays cleanly. + function clear() { + for (var i = 0; i < overlays.length; i++) { + var entry = overlays[i]; + var ids = Object.keys(entry.players); + for (var j = 0; j < ids.length; j++) { + var player = entry.players[ids[j]]; + if (player && player.parentNode) { + player.parentNode.removeChild(player); + } + } + entry.players = {}; + if (entry.el && entry.el.parentNode) { + entry.el.parentNode.removeChild(entry.el); + } + } + overlays.length = 0; + } + + // Re-place every overlay over its content iframe's CURRENT box. The host (e.g. the + // editor preview panel) can move the iframe with a CSS transform (slide-in), which + // fires no scroll/resize, so an overlay placed during the animation would stay at + // its sync-time position. The host calls this once the move settles. + function reflow() { + for (var i = 0; i < overlays.length; i++) { + positionOverlay(overlays[i]); + } + } + + return { + onMessage: onMessage, + sync: sync, + clear: clear, + reflow: reflow, + validate: function (raw, contentSrc) { + return validate(raw, contentSrc, { strict: strict, whitelist: whitelist }); + }, + /* v8 ignore start */ + init: function () { + window.addEventListener('message', onMessage); + window.addEventListener('resize', scheduleReflow); + window.addEventListener('scroll', scheduleReflow, true); + window.addEventListener('load', pingAll); + pingAll(); + window.setTimeout(pingAll, 500); + return this; + } + /* v8 ignore stop */ + }; + } + + /** + * Bootstrap: create a relay from config and start listening. + * + * @param {Object} config {mode: 'open'|'strict', whitelist: string[]} + * @returns {Object} + */ + /* v8 ignore next 3 */ + function init(config) { + return createRelay(config).init(); + } + + var exp = { + buildWhitelist: buildWhitelist, + contentDir: contentDir, + packageId: packageId, + isSameOriginPackageFile: isSameOriginPackageFile, + isIpOrLocalHost: isIpOrLocalHost, + normalizeHost: normalizeHost, + isRelatedToLms: isRelatedToLms, + isCrossOriginHttps: isCrossOriginHttps, + reconstructProvider: reconstructProvider, + validate: validate, + makePlayer: makePlayer, + createRelay: createRelay, + init: init + }; + // Test runner (Vitest/Node) consumes module.exports. + if (typeof module !== 'undefined' && module.exports) { module.exports = exp; } + // Browser bootstrap (view.php) consumes window.exeEmbedRelay. + if (typeof window !== 'undefined') { window.exeEmbedRelay = exp; } +})(); diff --git a/src/embed/exe_embed_shim.js b/src/embed/exe_embed_shim.js new file mode 100644 index 0000000..ec63c3a --- /dev/null +++ b/src/embed/exe_embed_shim.js @@ -0,0 +1,316 @@ +/** + * In-iframe external-embed shim for the opaque-origin preview / secure package mode. + * + * Loaded from the of every rendered page. In opaque-origin mode the document + * runs in a sandbox without allow-same-origin, so the sandbox flag propagates to nested + * iframes and cross-origin players (YouTube, Vimeo) plus PDFs render blank. This shim + * replaces each cross-origin (https) or .pdf iframe with a same-size placeholder and + * reports its geometry to the parent, which validates it and overlays the real player + * inline (see exe_embed_relay.js). It self-activates ONLY in the opaque origin, so the + * same file stays dormant in same-origin rendering (where external players already + * render inline and the relay is not loaded). + * + * There is no host list here: the shim promotes any cross-origin https (or .pdf) iframe + * as a candidate and the parent relay is the authoritative gate (open vs strict mode). + * postMessage targetOrigin is '*' because the opaque origin has no stable value; the + * parent authenticates messages by event.source instead. + * + * Exposed two ways from a single body: window.exeEmbedShim (browser bootstrap) and + * module.exports (tests). + * + * CANONICAL SOURCE for the eXeLearning embedder family lives here in eXeLearning core + * (public/app/common/exe_embed_bridge/exe_embed_shim.js). The host plugins + * (mod_exelearning, wp-exelearning, omeka-s-exelearning, procomun) mirror this logic + * (only the export wrapper differs). Keep them in sync; changes flow from core outward. + */ +(function () { + 'use strict'; + + /** + * Whether this document runs in an opaque origin (secure sandbox). In an opaque + * origin document.cookie throws and window.origin is "null". + * + * @returns {boolean} + */ + function isOpaqueOrigin() { + try { + void document.cookie; + return window.origin === 'null'; + } catch (e) { + return true; + } + } + + /** + * Whether a URL path ends in .pdf (PDFs also fail under the opaque sandbox). + * + * @param {string} url + * @returns {boolean} + */ + function isPdfUrl(url) { + try { + return /\.pdf$/i.test(new URL(url, window.location.href).pathname); + } catch (e) { + return false; + } + } + + /** + * Whether a src resolves to an https URL on a host other than this document's own + * (served) host -- i.e. a cross-origin external embed. The opaque document is still + * served from the platform, so window.location.hostname is the platform host and the + * comparison is reliable. The parent relay re-validates authoritatively (DEC-0061); + * this is only a candidate filter so same-origin content iframes are left untouched. + * + * @param {string} src + * @returns {boolean} + */ + function isCrossOriginHttps(src) { + try { + var u = new URL(src, window.location.href); + // Strip a single trailing dot so the LMS host in its FQDN-root form + // ('host.') counts as same-host and is not reported as a candidate. + var host = u.hostname.toLowerCase().replace(/\.$/, ''); + var here = window.location.hostname.toLowerCase().replace(/\.$/, ''); + return u.protocol === 'https:' && host !== here; + } catch (e) { + return false; + } + } + + /** + * Whether an iframe src should be promoted to the parent: any cross-origin https + * embed or a .pdf (both render blank under the opaque sandbox). No host list -- the + * parent relay decides what actually renders (open vs strict mode). + * + * @param {string} src + * @returns {boolean} + */ + function isPromotable(src) { + return isCrossOriginHttps(src) || isPdfUrl(src); + } + + /** + * Recognise a known video provider from an embed src and extract its object id, so the + * shim can report {provider, objectId} instead of the author URL (DEC-0067 id-only + * channel). The parent rebuilds the canonical URL from a fixed template; this avoids + * passing the author's URL across the boundary for recognised providers. Returns null + * for unknown hosts or unexpected paths (the caller then falls back to URL mode). The + * id shape is intentionally permissive here; the parent re-checks it against a strict + * regex before templating it. + * + * @param {string} src + * @returns {?{provider: string, objectId: string}} + */ + function extractProvider(src) { + var u; + try { + u = new URL(src, window.location.href); + } catch (e) { + return null; + } + if (u.protocol !== 'https:') { + return null; + } + var host = u.hostname.toLowerCase().replace(/\.$/, ''); + var m; + if (host === 'youtu.be') { + m = u.pathname.match(/^\/([A-Za-z0-9_-]{6,})$/); + return m ? { provider: 'youtube', objectId: m[1] } : null; + } + if (host.indexOf('youtube') !== -1) { + m = u.pathname.match(/^\/embed\/([A-Za-z0-9_-]{6,})$/); + return m ? { provider: 'youtube', objectId: m[1] } : null; + } + if (host.indexOf('vimeo') !== -1) { + m = u.pathname.match(/^\/video\/([0-9]+)$/); + return m ? { provider: 'vimeo', objectId: m[1] } : null; + } + if (host.indexOf('dailymotion') !== -1) { + m = u.pathname.match(/^\/embed\/video\/([A-Za-z0-9]{5,})$/); + return m ? { provider: 'dailymotion', objectId: m[1] } : null; + } + if (host === 'mediateca.educa.madrid.org') { + m = u.pathname.match(/^\/video\/([A-Za-z0-9]{8,})(?:\/fs)?$/); + return m ? { provider: 'mediateca-madrid', objectId: m[1] } : null; + } + return null; + } + + /** + * Render a width/height attribute value as a CSS length. + * + * @param {?string} value + * @param {string} fallback + * @returns {string} + */ + function cssSize(value, fallback) { + if (!value) { + return fallback; + } + return /^[0-9]+$/.test(String(value)) ? value + 'px' : String(value); + } + + /** + * Replace whitelisted/PDF iframes with placeholders that reserve their box and + * carry the embed id + url. Returns the created placeholder elements. + * + * @param {Document|Element} root A document or a container element to scan. + * @param {Object} counter {n:int} mutable id counter (kept across calls). + * @returns {Element[]} + */ + function promote(root, counter) { + var created = []; + var maker = root.ownerDocument || root; + var frames = root.querySelectorAll('iframe[src]'); + for (var i = 0; i < frames.length; i++) { + var frame = frames[i]; + if (frame.getAttribute('data-exe-embed-id')) { + continue; + } + var src = frame.getAttribute('src'); + if (!isPromotable(src)) { + continue; + } + var rect = frame.getBoundingClientRect ? frame.getBoundingClientRect() : { width: 0, height: 0 }; + var placeholder = maker.createElement('div'); + counter.n += 1; + placeholder.setAttribute('data-exe-embed-id', 'exe-embed-' + counter.n); + // Report an ABSOLUTE url: the shim runs inside the content, so resolve the + // (possibly relative) src against the content location. The parent relay + // cannot — it would resolve a relative url against the host page instead. + var absoluteUrl = src; + try { + absoluteUrl = new URL(src, window.location.href).href; + } catch (e) { + absoluteUrl = src; + } + placeholder.setAttribute('data-exe-embed-url', absoluteUrl); + // For recognised providers also stamp {provider, objectId} so the parent can + // rebuild the canonical URL from a fixed template (DEC-0067 id-only channel) + // instead of trusting the author URL. Unknown hosts keep URL-only mode. + var provider = extractProvider(absoluteUrl); + if (provider) { + placeholder.setAttribute('data-exe-embed-provider', provider.provider); + placeholder.setAttribute('data-exe-embed-object-id', provider.objectId); + } + placeholder.className = frame.className; + placeholder.style.display = 'block'; + placeholder.style.maxWidth = '100%'; + placeholder.style.width = cssSize(frame.getAttribute('width'), (rect.width || 0) + 'px'); + placeholder.style.height = cssSize(frame.getAttribute('height'), (rect.height || 0) + 'px'); + placeholder.style.background = '#000'; + frame.parentNode.replaceChild(placeholder, frame); + created.push(placeholder); + } + return created; + } + + /** + * Collect the geometry of every placeholder in the document. + * + * @param {Document} doc + * @returns {Object[]} + */ + function collect(doc) { + var embeds = []; + var nodes = doc.querySelectorAll('[data-exe-embed-id]'); + for (var i = 0; i < nodes.length; i++) { + var node = nodes[i]; + var rect = node.getBoundingClientRect(); + var rec = { + id: node.getAttribute('data-exe-embed-id'), + url: node.getAttribute('data-exe-embed-url'), + x: rect.left, + y: rect.top, + w: rect.width, + h: rect.height + }; + var provider = node.getAttribute('data-exe-embed-provider'); + var objectId = node.getAttribute('data-exe-embed-object-id'); + if (provider && objectId) { + rec.provider = provider; + rec.objectId = objectId; + } + embeds.push(rec); + } + return embeds; + } + + /** + * Bootstrap inside the package iframe (no-op outside the secure opaque origin). + * Browser-only glue (requires a framed, opaque-origin window); exercised by the + * Playwright/Firefox e2e (tests/e2e/embed.spec.cjs), not the happy-dom unit tests. + */ + /* v8 ignore start */ + function init() { + if (window.parent === window || !isOpaqueOrigin()) { + return; + } + var counter = { n: 0 }; + var scheduled = false; + + function report() { + window.parent.postMessage({ type: 'exe-embed', action: 'sync', embeds: collect(document) }, '*'); + } + function schedule() { + if (scheduled) { + return; + } + scheduled = true; + window.requestAnimationFrame(function () { + scheduled = false; + report(); + }); + } + function run() { + promote(document, counter); + report(); + } + + run(); + if (window.MutationObserver) { + new MutationObserver(function () { + promote(document, counter); + schedule(); + }).observe(document.documentElement, { childList: true, subtree: true }); + } + window.addEventListener('scroll', schedule, true); + window.addEventListener('resize', schedule); + window.addEventListener('load', report); + window.addEventListener('message', function (event) { + if (event.source !== window.parent) { + return; + } + var data = event.data; + if (data && data.type === 'exe-embed' && data.action === 'request') { + run(); + } + }); + } + /* v8 ignore stop */ + + var exp = { + isOpaqueOrigin: isOpaqueOrigin, + isPdfUrl: isPdfUrl, + isCrossOriginHttps: isCrossOriginHttps, + isPromotable: isPromotable, + extractProvider: extractProvider, + promote: promote, + collect: collect, + init: init + }; + // Test runner (Vitest/Node) consumes module.exports. + if (typeof module !== 'undefined' && module.exports) { module.exports = exp; } + // Browser bootstrap consumes window.exeEmbedShim; auto-run inside the iframe. + if (typeof window !== 'undefined') { + window.exeEmbedShim = exp; + if (typeof document !== 'undefined') { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + } + } +})(); diff --git a/src/embed/exe_media_host.js b/src/embed/exe_media_host.js new file mode 100644 index 0000000..b80265f --- /dev/null +++ b/src/embed/exe_media_host.js @@ -0,0 +1,533 @@ +/** + * exe-media-host — reference PARENT-side relay for the eXeLearning external-media bridge. + * + * Host plugins (Moodle, WordPress, Procomún, …) include this one file on the trusted page + * that embeds an eXeLearning package inside an opaque-origin sandboxed iframe. It lets the + * package's YouTube/Vimeo videos play (which they cannot do inside the opaque frame) by: + * + * - completing the capability handshake announced by the child runtime (window identity + * + per-view nonce + a transferred MessageChannel port), + * - opening an accessible with the real provider player on the trusted side, and + * - relaying a tiny, validated set of media commands/events over the private port. + * + * Security: the relay NEVER trusts `event.origin` (it is the string "null"). The only + * window-level message accepted is the child's `welcome`-triggering `hello`, gated by + * `event.source === iframe.contentWindow`. Steady-state media traffic flows only over the + * private port. The child sends just `{provider, videoId}`; the relay reconstructs the + * canonical URL from a fixed per-provider template (never a child-supplied URL). + * + * Classic browser script (no ES module syntax); depends on the shared `exeMediaPolicy`. + * + * mod_exelearning VARIANT (DEC-0067): vendored from eXeLearning's exe-media-host.js, but the + * provider adapters drive the player by RAW postMessage (YouTube enablejsapi=1, Vimeo api=1) + * instead of the YouTube IFrame Player API / Vimeo Player SDK, so the trusted Moodle page + * never loads third-party player script (erseco). The handshake, accessible modal and the + * command/event relay are otherwise unchanged. The child runtime (exe_media_bridge.js + + * exe_media_policy.js, baked into the package by eXeLearning) is unmodified; the interactive + * video iDevice already speaks this contract (window.exeMediaBridge.openMedia → controller). + * Canonical upstream: exelearning/public/app/common/exe_media_bridge/. + * + * @license AGPL-3.0 + */ +(function (root) { + 'use strict'; + + var policy = root.exeMediaPolicy; + var TYPE = policy.TYPE; + var VERSION = policy.VERSION; + + function tr(key) { + return typeof root._ === 'function' ? root._(key) : key; + } + + function isThenable(v) { + return v && typeof v.then === 'function'; + } + + function genNonce(opts) { + if (opts && typeof opts.genId === 'function') return opts.genId(); + if (root.crypto && typeof root.crypto.randomUUID === 'function') return root.crypto.randomUUID(); + return 'n-' + Math.random().toString(36).slice(2); + } + + function makeChannel(opts) { + if (opts && typeof opts.channelFactory === 'function') return opts.channelFactory(); + return new MessageChannel(); + } + + function getInterval(opts) { + return { + set: (opts && opts.setInterval) || (root.setInterval ? root.setInterval.bind(root) : function () {}), + clear: (opts && opts.clearInterval) || (root.clearInterval ? root.clearInterval.bind(root) : function () {}), + }; + } + + // ---- Raw-postMessage provider adapters (NO YouTube IFrame API / Vimeo SDK) ---------- + // mod_exelearning controls the promoted player by posting the providers' documented + // postMessage commands straight to the player iframe and parsing its event messages, so + // the trusted Moodle page never loads third-party player SDK script (erseco, DEC-0067). + // Both providers use JSON-STRING messages. The host polls getCurrentTime()/getDuration() + // every 250ms to emit timeupdate; these adapters keep those values fresh from the + // player's pushed infoDelivery (YouTube) / timeupdate (Vimeo) events. Adapters are still + // injectable (opts.youtubeFactory / opts.vimeoFactory) for tests. + + function num(v) { + return typeof v === 'number' && isFinite(v) ? v : undefined; + } + + function parentOrigin() { + try { + return (root.location && root.location.origin) || ''; + } catch (e) { + return ''; + } + } + + // Pure command builders (exposed for unit tests). Closed action set; null otherwise. + function ytCommand(action, value) { + switch (action) { + case 'play': return { event: 'command', func: 'playVideo', args: [] }; + case 'pause': return { event: 'command', func: 'pauseVideo', args: [] }; + case 'seek': return { event: 'command', func: 'seekTo', args: [Number(value) || 0, true] }; + case 'listen': return { event: 'listening' }; + default: return null; + } + } + function vimeoCommand(action, value) { + switch (action) { + case 'play': return { method: 'play' }; + case 'pause': return { method: 'pause' }; + case 'seek': return { method: 'setCurrentTime', value: Number(value) || 0 }; + default: return null; + } + } + + // Pure inbound-event parsers (exposed for unit tests). Both providers post JSON strings. + function parseRaw(raw) { + if (typeof raw === 'string') { + try { return JSON.parse(raw); } catch (e) { return null; } + } + return raw && typeof raw === 'object' ? raw : null; + } + function parseYtEvent(raw) { + var d = parseRaw(raw); + if (!d || typeof d.event !== 'string') return null; + if (d.event === 'onReady') return { kind: 'ready' }; + if (d.event === 'onError') return { kind: 'error', code: String(d.info != null ? d.info : '') }; + if (d.event === 'infoDelivery' && d.info && typeof d.info === 'object') { + return { kind: 'info', currentTime: num(d.info.currentTime), duration: num(d.info.duration), playerState: num(d.info.playerState) }; + } + if (d.event === 'onStateChange') { + return { kind: 'state', playerState: num(typeof d.info === 'object' && d.info ? d.info.playerState : d.info) }; + } + return null; + } + function parseVimeoEvent(raw) { + var d = parseRaw(raw); + if (!d || typeof d.event !== 'string') return null; + if (d.event === 'ready') return { kind: 'ready' }; + if (d.event === 'timeupdate' && d.data) return { kind: 'timeupdate', currentTime: num(d.data.seconds), duration: num(d.data.duration) }; + if (d.event === 'play') return { kind: 'play' }; + if (d.event === 'pause') return { kind: 'pause' }; + if (d.event === 'ended' || d.event === 'finish') return { kind: 'ended' }; + if (d.event === 'error') return { kind: 'error', code: 'vimeo_error' }; + return null; + } + + function youtubeRawAdapter(container, videoId, cb) { + var doc = container.ownerDocument || root.document; + var origin = parentOrigin(); + var target = 'https://www.youtube-nocookie.com'; + var src = target + '/embed/' + videoId + '?enablejsapi=1&rel=0&modestbranding=1' + + (origin ? '&origin=' + encodeURIComponent(origin) : '') + + (cb.start ? '&start=' + Math.floor(cb.start) : '') + + (cb.autoplay ? '&autoplay=1' : ''); + var frame = doc.createElement('iframe'); + frame.setAttribute('src', src); + frame.setAttribute('allow', 'autoplay; encrypted-media; fullscreen; picture-in-picture'); + frame.setAttribute('allowfullscreen', ''); + frame.setAttribute('referrerpolicy', 'strict-origin-when-cross-origin'); + frame.style.cssText = 'border:0;width:100%;height:100%;'; + var cache = { currentTime: 0, duration: 0 }; + + function post(msg) { + if (!msg) return; + try { frame.contentWindow.postMessage(JSON.stringify(msg), target); } catch (e) { /* not ready */ } + } + function onMessage(e) { + if (e.source !== frame.contentWindow) return; + var ev = parseYtEvent(e.data); + if (!ev) return; + if (ev.kind === 'ready') { + if (cb.onReady) cb.onReady(cache.duration || undefined); + } else if (ev.kind === 'info') { + if (typeof ev.currentTime === 'number') cache.currentTime = ev.currentTime; + if (typeof ev.duration === 'number') cache.duration = ev.duration; + signalState(ev.playerState); + } else if (ev.kind === 'state') { + signalState(ev.playerState); + } else if (ev.kind === 'error' && cb.onError) { + cb.onError(ev.code); + } + } + function signalState(s) { + if (s === 1 && cb.onPlay) cb.onPlay(); + else if (s === 2 && cb.onPause) cb.onPause(); + else if (s === 0 && cb.onEnded) cb.onEnded(); + } + if (root.addEventListener) root.addEventListener('message', onMessage); + frame.addEventListener('load', function () { post(ytCommand('listen')); }); + container.appendChild(frame); + + return { + play: function () { post(ytCommand('play')); }, + pause: function () { post(ytCommand('pause')); }, + seek: function (t) { post(ytCommand('seek', t)); }, + getCurrentTime: function () { return cache.currentTime; }, + getDuration: function () { return cache.duration; }, + destroy: function () { + if (root.removeEventListener) root.removeEventListener('message', onMessage); + if (frame.parentNode) frame.parentNode.removeChild(frame); + }, + }; + } + + function vimeoRawAdapter(container, videoId, cb) { + var doc = container.ownerDocument || root.document; + var target = 'https://player.vimeo.com'; + var pid = 'exe-vimeo-' + videoId; + var src = target + '/video/' + videoId + '?api=1&player_id=' + encodeURIComponent(pid) + + (cb.autoplay ? '&autoplay=1' : ''); + var frame = doc.createElement('iframe'); + frame.setAttribute('src', src); + frame.setAttribute('id', pid); + frame.setAttribute('allow', 'autoplay; fullscreen; picture-in-picture'); + frame.setAttribute('allowfullscreen', ''); + frame.setAttribute('referrerpolicy', 'strict-origin-when-cross-origin'); + frame.style.cssText = 'border:0;width:100%;height:100%;'; + var cache = { currentTime: 0, duration: 0 }; + + function post(msg) { + if (!msg) return; + try { frame.contentWindow.postMessage(JSON.stringify(msg), target); } catch (e) { /* not ready */ } + } + function subscribe() { + ['timeupdate', 'play', 'pause', 'ended', 'error'].forEach(function (name) { + post({ method: 'addEventListener', value: name }); + }); + if (cb.start) post(vimeoCommand('seek', cb.start)); + } + function onMessage(e) { + if (e.source !== frame.contentWindow) return; + var ev = parseVimeoEvent(e.data); + if (!ev) return; + if (ev.kind === 'ready') { + subscribe(); + if (cb.onReady) cb.onReady(cache.duration || undefined); + } else if (ev.kind === 'timeupdate') { + if (typeof ev.currentTime === 'number') cache.currentTime = ev.currentTime; + if (typeof ev.duration === 'number') cache.duration = ev.duration; + } else if (ev.kind === 'play' && cb.onPlay) { + cb.onPlay(); + } else if (ev.kind === 'pause' && cb.onPause) { + cb.onPause(); + } else if (ev.kind === 'ended' && cb.onEnded) { + cb.onEnded(); + } else if (ev.kind === 'error' && cb.onError) { + cb.onError(ev.code); + } + } + if (root.addEventListener) root.addEventListener('message', onMessage); + container.appendChild(frame); + + return { + play: function () { post(vimeoCommand('play')); }, + pause: function () { post(vimeoCommand('pause')); }, + seek: function (t) { post(vimeoCommand('seek', t)); }, + getCurrentTime: function () { return cache.currentTime; }, + getDuration: function () { return cache.duration; }, + destroy: function () { + if (root.removeEventListener) root.removeEventListener('message', onMessage); + if (frame.parentNode) frame.parentNode.removeChild(frame); + }, + }; + } + + // ---- Accessible modal ------------------------------------------------------------- + + function buildModal(session) { + var opts = session.opts || {}; + var doc = opts.document || root.document; + var dialog = doc.createElement('dialog'); + dialog.className = 'exe-media-modal'; + dialog.setAttribute('aria-label', tr('Video player')); + try { + dialog.setAttribute('closedby', 'any'); // light-dismiss where supported + } catch (e) { + /* older engines */ + } + + var closeBtn = doc.createElement('button'); + closeBtn.setAttribute('type', 'button'); + closeBtn.className = 'exe-media-modal__close'; + closeBtn.setAttribute('aria-label', tr('Close video')); + closeBtn.textContent = tr('Close'); + closeBtn.addEventListener('click', function () { + requestClose(session); + }); + + var body = doc.createElement('div'); + body.className = 'exe-media-modal__body'; + + dialog.appendChild(closeBtn); + dialog.appendChild(body); + + // Safari light-dismiss fallback: a click whose target is the dialog itself is the backdrop. + dialog.addEventListener('click', function (e) { + if (e.target === dialog) requestClose(session); + }); + // Esc / platform close request. + dialog.addEventListener('close', function () { + if (session.hiding) { + session.hiding = false; + return; + } + requestClose(session); + }); + + (doc.body || doc.documentElement).appendChild(dialog); + if (typeof dialog.showModal === 'function') dialog.showModal(); + + session.dialog = dialog; + return body; + } + + function showModal(session) { + if (session.dialog && typeof session.dialog.showModal === 'function') session.dialog.showModal(); + } + + function hideModal(session) { + if (session.dialog && typeof session.dialog.close === 'function') { + session.hiding = true; + session.dialog.close(); + } + } + + function requestClose(session) { + if (session.closed) return; + session.closed = true; + sendEvent(session, { action: 'closed' }); + destroyAdapter(session); + if (session.dialog) { + if (typeof session.dialog.close === 'function') session.dialog.close(); + if (typeof session.dialog.remove === 'function') session.dialog.remove(); + } + } + + function destroyAdapter(session) { + if (session.pollTimer != null) { + session.interval.clear(session.pollTimer); + session.pollTimer = null; + } + if (session.adapter && session.adapter.destroy) session.adapter.destroy(); + session.adapter = null; + } + + // ---- Media event relay ------------------------------------------------------------ + + function sendEvent(session, ev) { + ev.type = TYPE; + ev.v = VERSION; + if (session.port1) session.port1.postMessage(ev); + } + + function emitTimeupdate(session, ct, dur) { + if (typeof ct === 'number' && isFinite(ct) && typeof dur === 'number' && isFinite(dur)) { + sendEvent(session, { action: 'timeupdate', currentTime: ct, duration: dur }); + } + } + + function startPolling(session) { + if (session.pollTimer != null) return; + session.pollTimer = session.interval.set(function () { + if (!session.adapter) return; + var ct = session.adapter.getCurrentTime(); + var dur = session.adapter.getDuration(); + if (isThenable(ct) || isThenable(dur)) { + Promise.all([Promise.resolve(ct), Promise.resolve(dur)]).then(function (r) { + emitTimeupdate(session, r[0], r[1]); + }); + } else { + emitTimeupdate(session, ct, dur); + } + }, 250); + } + + function openMedia(session, provider, videoId, openOpts) { + var opts = session.opts || {}; + // Single active media per session: discard any previous player/poll-timer/ + // before opening a new one, so repeated 'open' commands cannot stack modals or orphan + // provider players (host-page resource exhaustion). Remove the old dialog directly + // (no .close()) so its 'close' listener does not fire a spurious 'closed' to the child. + destroyAdapter(session); + if (session.dialog) { + if (typeof session.dialog.remove === 'function') session.dialog.remove(); + session.dialog = null; + } + var container = buildModal(session); + var callbacks = { + start: openOpts.start, + autoplay: openOpts.autoplay, + onReady: function (duration) { + sendEvent(session, { action: 'ready', duration: duration }); + startPolling(session); + }, + onPlay: function () { sendEvent(session, { action: 'play' }); }, + onPause: function () { sendEvent(session, { action: 'pause' }); }, + onEnded: function () { sendEvent(session, { action: 'ended' }); }, + onSeeked: function (t) { sendEvent(session, { action: 'seeked', currentTime: t }); }, + onError: function (code) { sendEvent(session, { action: 'error', code: String(code), fatal: true }); }, + }; + var factory = + provider === 'vimeo' + ? opts.vimeoFactory || vimeoRawAdapter + : opts.youtubeFactory || youtubeRawAdapter; + session.adapter = factory(container, videoId, callbacks); + } + + function answerState(session, reqId, field) { + if (!session.adapter) return; + var value = field === 'duration' ? session.adapter.getDuration() : session.adapter.getCurrentTime(); + Promise.resolve(value).then(function (v) { + var ev = { action: 'state', reqId: reqId }; + ev[field === 'duration' ? 'duration' : 'currentTime'] = v; + sendEvent(session, ev); + }); + } + + function processCommand(session, data) { + if (!data || data.type !== TYPE || data.v !== VERSION) return; + if (data.exelearningBridge !== session.nonce) return; // nonce gate + if (!policy.COMMANDS[data.action]) return; + + if (data.action === 'open') { + var url = policy.canonicalEmbedUrl(data.provider, data.videoId); + if (!url) { + sendEvent(session, { action: 'error', code: 'unsupported_provider', fatal: true }); + return; + } + session.closed = false; + openMedia(session, data.provider, data.videoId, { start: data.start, autoplay: data.autoplay }); + return; + } + if (!policy.validateCommand(data, session.nonce)) return; + + var a = session.adapter; + switch (data.action) { + case 'play': if (a) a.play(); break; + case 'pause': if (a) a.pause(); break; + case 'seek': if (a) a.seek(data.t); break; + case 'getCurrentTime': answerState(session, data.reqId, 'currentTime'); break; + case 'getDuration': answerState(session, data.reqId, 'duration'); break; + case 'hide': hideModal(session); break; + case 'show': showModal(session); break; + case 'close': requestClose(session); break; + default: break; + } + } + + // ---- Handshake + attachment ------------------------------------------------------- + + var records = []; + + function teardown(session) { + if (!session) return; + destroyAdapter(session); + if (session.port1 && session.port1.close) session.port1.close(); + if (session.dialog && session.dialog.remove) session.dialog.remove(); + } + + function handleHello(rec, data) { + var existing = rec.session; + if (existing && existing.helloId === data.helloId) return; // duplicate + if (existing) teardown(existing); // new helloId → re-pair + + var nonce = genNonce(rec.opts); + var channel = makeChannel(rec.opts); + var session = { + iframe: rec.iframe, + opts: rec.opts, + nonce: nonce, + helloId: data.helloId, + port1: channel.port1, + adapter: null, + dialog: null, + pollTimer: null, + hiding: false, + closed: false, + interval: getInterval(rec.opts), + }; + rec.session = session; + + channel.port1.onmessage = function (e) { + processCommand(session, e.data); + }; + if (typeof channel.port1.start === 'function') channel.port1.start(); + + rec.iframe.contentWindow.postMessage( + { type: TYPE, v: VERSION, action: 'welcome', helloId: data.helloId, exelearningBridge: nonce }, + '*', + [channel.port2], + ); + } + + function onWindowMessage(rec, e) { + if (!rec.iframe || !rec.iframe.contentWindow || e.source !== rec.iframe.contentWindow) return; + if (policy.isHello(e.data)) handleHello(rec, e.data); + } + + /** + * Register an iframe that renders eXeLearning content and start relaying its media. + * @returns {{detach: function}} handle + */ + function attach(iframe, opts) { + opts = opts || {}; + var win = opts.win || root; + var rec = { iframe: iframe, opts: opts, session: null, win: win, handler: null }; + rec.handler = function (e) { + onWindowMessage(rec, e); + }; + if (win.addEventListener) win.addEventListener('message', rec.handler); + records.push(rec); + return { + detach: function () { + if (win.removeEventListener) win.removeEventListener('message', rec.handler); + teardown(rec.session); + var i = records.indexOf(rec); + if (i >= 0) records.splice(i, 1); + }, + }; + } + + function resetForTests() { + for (var i = 0; i < records.length; i++) { + var rec = records[i]; + if (rec.win && rec.win.removeEventListener) rec.win.removeEventListener('message', rec.handler); + teardown(rec.session); + } + records = []; + } + + root.exeMediaHost = { + attach: attach, + buildModal: buildModal, + _youtubeAdapter: youtubeRawAdapter, + _vimeoAdapter: vimeoRawAdapter, + _ytCommand: ytCommand, + _vimeoCommand: vimeoCommand, + _parseYtEvent: parseYtEvent, + _parseVimeoEvent: parseVimeoEvent, + _processCommand: processCommand, + _resetForTests: resetForTests, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/src/embed/exe_media_policy.js b/src/embed/exe_media_policy.js new file mode 100644 index 0000000..34cf942 --- /dev/null +++ b/src/embed/exe_media_policy.js @@ -0,0 +1,258 @@ +/** + * exe_media_policy — pure, framework-free policy for the external-media bridge. + * + * Single source of truth shared by: + * - the child runtime (exe_media_bridge.js) that runs inside exported content, and + * - the reference parent relay (exe-media-host.js) that runs in the trusted host page. + * + * Responsibilities (all pure — no DOM mutation, no postMessage): + * - Detect and normalize supported external media (YouTube, Vimeo) into a neutral + * descriptor; recognize PDFs for a deferred open-in-new-tab fallback. + * - Rebuild canonical, privacy-friendly embed URLs from a bare provider id, so the + * bridge never has to trust an attacker-supplied URL (kills redirect-laundering). + * - Validate the versioned postMessage/MessageChannel contract (handshake, commands, + * events) with a strict schema, a closed action enum and a provider allowlist. + * + * This file is a CLASSIC browser script (no ES module syntax) so it can be shipped + * verbatim inside exported packages and loaded over file:// — while still being + * importable (for its side effect) by the Vitest suite, which reads the global it sets. + * + * @license AGPL-3.0 + */ +(function (root) { + 'use strict'; + + var TYPE = 'exe-media'; + var VERSION = 1; + + // Bare-id shapes. YouTube ids are exactly 11 url-safe chars; Vimeo ids are numeric. + var ID_RE = { + youtube: /^[A-Za-z0-9_-]{11}$/, + vimeo: /^[0-9]{6,12}$/, + }; + + // Host allow-lists per provider. Anything else never matches a provider, so IPs, + // loopback, *.local and look-alike domains (evil.com/watch?v=...) cannot slip in. + var YOUTUBE_HOSTS = { + 'youtube.com': 1, + 'www.youtube.com': 1, + 'm.youtube.com': 1, + 'music.youtube.com': 1, + 'youtube-nocookie.com': 1, + 'www.youtube-nocookie.com': 1, + }; + var YOUTU_BE_HOSTS = { 'youtu.be': 1, 'www.youtu.be': 1 }; + var VIMEO_HOSTS = { 'vimeo.com': 1, 'www.vimeo.com': 1 }; + var VIMEO_PLAYER_HOSTS = { 'player.vimeo.com': 1 }; + + // Closed action enums. Anything outside these is dropped before any work happens. + var COMMANDS = { open: 1, play: 1, pause: 1, seek: 1, getCurrentTime: 1, getDuration: 1, hide: 1, show: 1, close: 1 }; + var EVENTS = { ready: 1, play: 1, pause: 1, ended: 1, timeupdate: 1, seeked: 1, state: 1, error: 1, closed: 1 }; + + function isObject(v) { + return v !== null && typeof v === 'object'; + } + + function finiteNonNeg(n) { + return typeof n === 'number' && isFinite(n) && n >= 0; + } + + function isAllowedProvider(provider) { + return provider === 'youtube' || provider === 'vimeo'; + } + + function isValidVideoId(provider, id) { + var re = ID_RE[provider]; + return !!re && typeof id === 'string' && re.test(id); + } + + /** + * Build the canonical, privacy-friendly embed URL for a provider id. Returns null + * for unknown providers or ids that fail the provider's shape — so a malicious id + * can never be templated into a live URL. + */ + function canonicalEmbedUrl(provider, id) { + if (provider === 'youtube' && isValidVideoId('youtube', id)) { + return 'https://www.youtube-nocookie.com/embed/' + id; + } + if (provider === 'vimeo' && isValidVideoId('vimeo', id)) { + return 'https://player.vimeo.com/video/' + id; + } + return null; + } + + function firstPathSegment(pathname) { + var parts = pathname.split('/'); + for (var i = 0; i < parts.length; i++) { + if (parts[i]) return parts[i]; + } + return ''; + } + + function youtubeIdFromUrl(u) { + var host = u.hostname.toLowerCase(); + if (YOUTU_BE_HOSTS[host]) { + return firstPathSegment(u.pathname); + } + if (YOUTUBE_HOSTS[host]) { + var v = u.searchParams.get('v'); + if (v) return v; + var m = u.pathname.match(/^\/(?:embed|v|shorts)\/([^/?#]+)/); + if (m) return m[1]; + } + return ''; + } + + function vimeoIdFromUrl(u) { + var host = u.hostname.toLowerCase(); + if (VIMEO_PLAYER_HOSTS[host]) { + var m = u.pathname.match(/^\/video\/([0-9]+)/); + if (m) return m[1]; + } + if (VIMEO_HOSTS[host]) { + return firstPathSegment(u.pathname); + } + return ''; + } + + function descriptor(provider, id, originalUrl, embedUrl, extra) { + var d = { + provider: provider, + providerVideoId: id, + originalUrl: originalUrl, + embedUrl: embedUrl, + aspectRatio: provider === 'pdf' ? undefined : '16:9', + interactive: false, + requiresBridge: provider !== 'pdf', + }; + if (extra && extra.title) d.title = extra.title; + return d; + } + + /** + * Normalize a URL string or an element (iframe/anchor) into a neutral media + * descriptor, or null when it is not a supported external embed. + */ + function parseExternalMedia(input) { + var url = input; + var title; + if (input && typeof input.getAttribute === 'function') { + url = input.getAttribute('src') || input.getAttribute('href') || ''; + title = input.getAttribute('title') || undefined; + } + if (typeof url !== 'string' || !url) return null; + + var u; + try { + u = new URL(url); + } catch (e) { + return null; + } + // Reject userinfo (https://user@host/...) and non-web schemes outright. + if (u.username || u.password) return null; + if (u.protocol !== 'https:' && u.protocol !== 'http:') return null; + + var ytId = youtubeIdFromUrl(u); + if (ytId) { + if (!isValidVideoId('youtube', ytId)) return null; + return descriptor('youtube', ytId, url, canonicalEmbedUrl('youtube', ytId), { title: title }); + } + + var vId = vimeoIdFromUrl(u); + if (vId) { + if (!isValidVideoId('vimeo', vId)) return null; + return descriptor('vimeo', vId, url, canonicalEmbedUrl('vimeo', vId), { title: title }); + } + + if (u.pathname.toLowerCase().slice(-4) === '.pdf') { + return descriptor('pdf', null, url, url, { title: title }); + } + + return null; + } + + function hasEnvelope(d) { + return isObject(d) && d.type === TYPE && d.v === VERSION; + } + + function isHello(d) { + return hasEnvelope(d) && d.action === 'hello' && typeof d.helloId === 'string' && d.helloId.length > 0; + } + + function isWelcome(d) { + return ( + hasEnvelope(d) && + d.action === 'welcome' && + typeof d.helloId === 'string' && + typeof d.exelearningBridge === 'string' && + d.exelearningBridge.length > 0 + ); + } + + /** + * Validate a child→parent command. Defense in depth: envelope + a present, matching + * nonce (the `!!expectedNonce` guard prevents an absent expected nonce from ever + * authenticating a forged message) + closed action enum + per-action payload checks. + */ + function validateCommand(d, expectedNonce) { + if (!hasEnvelope(d)) return false; + if (!expectedNonce) return false; + if (typeof d.exelearningBridge !== 'string' || d.exelearningBridge !== expectedNonce) return false; + if (!COMMANDS[d.action]) return false; + switch (d.action) { + case 'open': + return ( + Number.isInteger(d.reqId) && + isAllowedProvider(d.provider) && + isValidVideoId(d.provider, d.videoId) && + (d.start == null || finiteNonNeg(d.start)) + ); + case 'seek': + return finiteNonNeg(d.t); + case 'getCurrentTime': + case 'getDuration': + return Number.isInteger(d.reqId); + default: + return true; // play / pause / hide / show / close — no payload + } + } + + /** + * Validate a parent→child event. Same envelope + closed enum + per-action payload. + */ + function validateEvent(d) { + if (!hasEnvelope(d)) return false; + if (!EVENTS[d.action]) return false; + switch (d.action) { + case 'timeupdate': + return finiteNonNeg(d.currentTime) && finiteNonNeg(d.duration); + case 'seeked': + return finiteNonNeg(d.currentTime); + case 'state': + return Number.isInteger(d.reqId); + case 'ready': + return d.duration == null || finiteNonNeg(d.duration); + case 'error': + return typeof d.code === 'string' && typeof d.fatal === 'boolean'; + default: + return true; // play / pause / ended / closed + } + } + + var api = { + TYPE: TYPE, + VERSION: VERSION, + COMMANDS: COMMANDS, + EVENTS: EVENTS, + parseExternalMedia: parseExternalMedia, + canonicalEmbedUrl: canonicalEmbedUrl, + isAllowedProvider: isAllowedProvider, + isValidVideoId: isValidVideoId, + isHello: isHello, + isWelcome: isWelcome, + validateCommand: validateCommand, + validateEvent: validateEvent, + }; + + root.exeMediaPolicy = api; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/src/embed/relay-host.ts b/src/embed/relay-host.ts new file mode 100644 index 0000000..3bb5869 --- /dev/null +++ b/src/embed/relay-host.ts @@ -0,0 +1,87 @@ +/** + * Loads the eXe-core external-media bridge into the trusted parent (the Viewer + * page) and drives it. + * + * - Channel A (`exe-embed`): overlays real YouTube/Vimeo/Dailymotion/PDF players + * over the geometry placeholders the shim reports from inside the opaque + * iframe. `exe_embed_relay.js` attaches `window.exeEmbedRelay`. + * - Channel B (`exe-media`): hosts the interactive-video iDevice modal bridge. + * `exe_media_host.js` (+ its `exe_media_policy.js` dependency) attaches + * `window.exeMediaHost`. + * + * The shim itself is NOT imported here — the PHP ContentController inlines it + * into the served package so it runs inside the opaque iframe. + */ +import './exe_embed_relay.js' +import './exe_media_policy.js' +import './exe_media_host.js' + +interface RelayInstance { + clear?: () => void + reflow?: () => void +} + +interface EmbedRelayApi { + init: (config: { mode?: string, whitelist?: string[] }) => RelayInstance +} + +interface MediaHostApi { + attach: (iframe: HTMLIFrameElement, opts: Record) => void +} + +interface BridgeGlobals { + exeEmbedRelay?: EmbedRelayApi + exeMediaHost?: MediaHostApi +} + +/** The bridge globals attached to `window` by the imported mirror scripts. */ +function globals(): BridgeGlobals { + return window as unknown as BridgeGlobals +} + +let relayInstance: RelayInstance | null = null + +/** + * Start the embed relay once (idempotent). Call after the first content iframe + * mounts. `open` mode overlays any cross-origin https player the shim reports. + */ +export function startRelay(): void { + if (relayInstance) { + return + } + const api = globals().exeEmbedRelay + if (api) { + relayInstance = api.init({ mode: 'open' }) + } +} + +/** + * Ask the shim inside the opaque iframe to (re)report its embed placeholders. + * @param iframe The opaque content iframe to ping. + */ +export function pingEmbeds(iframe: HTMLIFrameElement): void { + iframe.contentWindow?.postMessage({ type: 'exe-embed', action: 'request' }, '*') +} + +/** + * Attach the interactive-video media host to a content iframe (Channel B). + * @param iframe The opaque content iframe to attach the media host to. + */ +export function attachMedia(iframe: HTMLIFrameElement): void { + globals().exeMediaHost?.attach(iframe, {}) +} + +/** Remove all overlay players — call when the iframe is hidden or torn down. */ +export function clearOverlays(): void { + relayInstance?.clear?.() +} + +/** Reposition overlays against the iframe's current box — call after a resize/move. */ +export function reflowOverlays(): void { + relayInstance?.reflow?.() +} + +/** Test-only: reset the memoised relay instance between cases. */ +export function resetRelayForTests(): void { + relayInstance = null +} diff --git a/src/types/shims.d.ts b/src/types/shims.d.ts index c1a037e..20211fe 100644 --- a/src/types/shims.d.ts +++ b/src/types/shims.d.ts @@ -3,3 +3,11 @@ declare module '*.vue' { const component: DefineComponent export default component } + +// The eXe-core embed/media bridge mirrors are plain-JS, side-effect modules +// (they attach window.exeEmbedRelay / window.exeMediaHost). We import them only +// for their side effects and use the window globals via typed casts. +declare module '*/exe_embed_relay.js' +declare module '*/exe_embed_shim.js' +declare module '*/exe_media_policy.js' +declare module '*/exe_media_host.js' diff --git a/src/viewer/ElpxViewer.vue b/src/viewer/ElpxViewer.vue index 49f3aba..e4736b7 100644 --- a/src/viewer/ElpxViewer.vue +++ b/src/viewer/ElpxViewer.vue @@ -34,6 +34,7 @@ ', + ); + file_put_contents($this->distRoot . '/bundles/preview-fixed-resources.json', json_encode([ + 'resources' => [ + 'libs/jquery/jquery.min.js' => ['path' => 'base/jquery.min.js'], + 'theme:base/icon.svg' => ['path' => 'base/icon.svg'], + ], + ])); + $this->fixed = new FixedResourceManifest($this->distRoot); + $this->store = new PreviewSessionStore($this->root, new PreviewSessionLimits()); + + $this->previewId = $this->store->create('alice'); + $this->store->storeAssets($this->previewId, [ + ['key' => self::PHOTO_KEY, 'declaredSize' => 14, 'bytes' => 'PHOTO-BYTES-v1'], + ['key' => self::CLIP_KEY, 'declaredSize' => 10, 'bytes' => '0123456789'], + ]); + $this->store->applyRevision($this->previewId, [ + 'baseRevision' => 0, + 'nextRevision' => 1, + 'writes' => [ + ['path' => 'index.html', 'bytes' => 'hi'], + ['path' => 'img/inline.svg', 'bytes' => ''], + ], + 'deletes' => [], + 'assetRefs' => [ + 'content/resources/photo.png' => self::PHOTO_KEY, + 'media/clip.mp4' => self::CLIP_KEY, + ], + 'fixedRefs' => [ + 'libs/jquery/jquery.min.js' => 'libs/jquery/jquery.min.js', + 'theme/icon.svg' => 'theme:base/icon.svg', + ], + ], $this->fixed); + } + + protected function tearDown(): void { + $this->removeTree($this->root); + $this->removeTree($this->distRoot); + } + + private function server(): PreviewServer { + return new PreviewServer($this->store, $this->fixed); + } + + 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('"' . self::PHOTO_KEY . '"', $response->headers['ETag']); + self::assertSame('bytes', $response->headers['Accept-Ranges']); + self::assertArrayNotHasKey('Content-Security-Policy', $response->headers); + } + + public function testConditionalRequestReturns304(): void { + $response = $this->server()->serve( + $this->previewId, + 'content/resources/photo.png', + '"' . self::PHOTO_KEY . '"', + ); + self::assertSame(304, $response->status); + self::assertSame('', $response->body); + self::assertSame('nosniff', $response->headers['X-Content-Type-Options']); + self::assertSame('"' . self::PHOTO_KEY . '"', $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']); + } + + public function testUnsatisfiableRangeReturns416(): void { + $response = $this->server()->serve($this->previewId, 'media/clip.mp4', null, 'bytes=99-'); + self::assertSame(416, $response->status); + self::assertSame('bytes */10', $response->headers['Content-Range']); + } + + public function testServeFixedResourceIsAggressivelyCacheable(): void { + $response = $this->server()->serve($this->previewId, 'libs/jquery/jquery.min.js'); + self::assertSame(200, $response->status); + self::assertSame('window.jQuery=function(){};', $response->body); + self::assertSame('private, max-age=31536000', $response->headers['Cache-Control']); + self::assertSame('*', $response->headers['Access-Control-Allow-Origin']); + } + + public function testScriptableSvgFromFixedLayerCarriesCsp(): void { + $response = $this->server()->serve($this->previewId, 'theme/icon.svg'); + self::assertSame(200, $response->status); + self::assertSame('image/svg+xml; charset=utf-8', $response->headers['Content-Type']); + self::assertSame('private, max-age=31536000', $response->headers['Cache-Control']); + self::assertSame(PreviewPolicy::CSP, $response->headers['Content-Security-Policy']); + } + + public function testScriptableSvgFromSessionLayerCarriesCsp(): void { + $response = $this->server()->serve($this->previewId, 'img/inline.svg'); + self::assertSame(200, $response->status); + self::assertSame('image/svg+xml; charset=utf-8', $response->headers['Content-Type']); + self::assertSame(PreviewPolicy::CSP, $response->headers['Content-Security-Policy']); + } + + public function testUnknownPathIs404WithHardening(): void { + $response = $this->server()->serve($this->previewId, 'nope.css'); + self::assertSame(404, $response->status); + self::assertSame('no-store', $response->headers['Cache-Control']); + $this->assertBaseHardening($response->headers); + } + + public function testEncodedTraversalIs404(): void { + $response = $this->server()->serve($this->previewId, '%2e%2e%2fsecret'); + self::assertSame(404, $response->status); + self::assertSame('no-store', $response->headers['Cache-Control']); + } + + public function testInvalidPreviewIdIs404(): void { + $response = $this->server()->serve('not-a-uuid', 'index.html'); + self::assertSame(404, $response->status); + $this->assertBaseHardening($response->headers); + } + + public function testDeletedSessionStopsServing(): void { + $this->store->delete($this->previewId); + $response = $this->server()->serve($this->previewId, 'index.html'); + self::assertSame(404, $response->status); + self::assertSame('no-store', $response->headers['Cache-Control']); + } + + private function removeTree(string $dir): void { + if (!is_dir($dir)) { + @unlink($dir); + return; + } + foreach (scandir($dir) ?: [] as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $this->removeTree($dir . '/' . $item); + } + @rmdir($dir); + } +} diff --git a/tests/Unit/Service/Preview/PreviewSessionApiTest.php b/tests/Unit/Service/Preview/PreviewSessionApiTest.php new file mode 100644 index 0000000..9a01681 --- /dev/null +++ b/tests/Unit/Service/Preview/PreviewSessionApiTest.php @@ -0,0 +1,151 @@ +root = sys_get_temp_dir() . '/exe-api-' . bin2hex(random_bytes(6)); + $this->store = new PreviewSessionStore($this->root, new PreviewSessionLimits()); + $this->api = new PreviewSessionApi($this->store, new FixedResourceManifest($this->root . '/absent')); + } + + protected function tearDown(): void { + $this->removeTree($this->root); + } + + public function testCreateNegotiatesProtocolV2WithLimits(): void { + $result = $this->api->create('alice'); + self::assertSame(201, $result->status); + self::assertSame(2, $result->body['protocolVersion']); + self::assertSame(0, $result->body['revision']); + self::assertTrue(PreviewSessionApi::PROTOCOL_VERSION === 2); + self::assertArrayHasKey('previewId', $result->body); + self::assertArrayHasKey('recommendedBatchBytes', $result->body['limits']); + self::assertSame(5000, $result->body['limits']['maxFilesPerSession']); + } + + public function testMissingSessionIs404(): void { + $absent = '3f2a1b4c-5d6e-4f70-8a90-b1c2d3e4f506'; + self::assertSame(404, $this->api->uploadAssets($absent, 'alice', [])->status); + self::assertSame(404, $this->api->delete($absent, 'alice')->status); + self::assertSame(404, $this->api->publishRevision($absent, 'alice', $this->emptyMeta())->status); + } + + public function testForeignOwnerIs403(): void { + $id = $this->api->create('alice')->body['previewId']; + self::assertSame(403, $this->api->uploadAssets($id, 'bob', [])->status); + self::assertSame(403, $this->api->delete($id, 'bob')->status); + self::assertSame(403, $this->api->publishRevision($id, 'bob', $this->emptyMeta())->status); + } + + public function testUploadAssetsReturnsStoreResult(): void { + $id = $this->api->create('alice')->body['previewId']; + $result = $this->api->uploadAssets($id, 'alice', [ + ['key' => self::KEY, 'declaredSize' => 5, 'bytes' => 'PHOTO'], + ]); + self::assertSame(200, $result->status); + self::assertSame([self::KEY], $result->body['stored']); + self::assertSame([], $result->body['alreadyStored']); + self::assertSame([], $result->body['rejected']); + } + + public function testPublishRevisionSuccessBody(): void { + $id = $this->api->create('alice')->body['previewId']; + $result = $this->api->publishRevision($id, 'alice', [ + 'baseRevision' => 0, + 'nextRevision' => 1, + 'writes' => [['path' => 'index.html', 'bytes' => 'x']], + 'deletes' => [], + 'assetRefs' => [], + 'fixedRefs' => [], + ]); + self::assertSame(200, $result->status); + self::assertSame(['revision' => 1, 'active' => true], $result->body); + } + + public function testRevisionConflictBody(): void { + $id = $this->api->create('alice')->body['previewId']; + $this->api->publishRevision($id, 'alice', [ + 'baseRevision' => 0, 'nextRevision' => 1, + 'writes' => [['path' => 'index.html', 'bytes' => 'v1']], + 'deletes' => [], 'assetRefs' => [], 'fixedRefs' => [], + ]); + $stale = $this->api->publishRevision($id, 'alice', [ + 'baseRevision' => 0, 'nextRevision' => 1, + 'writes' => [['path' => 'index.html', 'bytes' => 'stale']], + 'deletes' => [], 'assetRefs' => [], 'fixedRefs' => [], + ]); + self::assertSame(409, $stale->status); + self::assertSame(['reason' => 'revision-conflict', 'currentRevision' => 1], $stale->body); + } + + public function testMissingAssetBody(): void { + $id = $this->api->create('alice')->body['previewId']; + $ghost = '99999999-9999-4999-8999-999999999999@deadbeef'; + $result = $this->api->publishRevision($id, 'alice', [ + 'baseRevision' => 0, 'nextRevision' => 1, 'writes' => [], 'deletes' => [], + 'assetRefs' => ['content/ghost.png' => $ghost], 'fixedRefs' => [], + ]); + self::assertSame(422, $result->status); + self::assertSame(['reason' => 'missing-assets', 'missing' => [$ghost]], $result->body); + } + + public function testUnknownFixedBody(): void { + $id = $this->api->create('alice')->body['previewId']; + $result = $this->api->publishRevision($id, 'alice', [ + 'baseRevision' => 0, 'nextRevision' => 1, 'writes' => [], 'deletes' => [], + 'assetRefs' => [], 'fixedRefs' => ['theme/x.css' => 'theme:unknown'], + ]); + self::assertSame(422, $result->status); + self::assertSame(['reason' => 'unknown-fixed-resources', 'resources' => ['theme:unknown']], $result->body); + } + + public function testDeleteBody(): void { + $id = $this->api->create('alice')->body['previewId']; + $result = $this->api->delete($id, 'alice'); + self::assertSame(200, $result->status); + self::assertSame(['success' => true], $result->body); + self::assertFalse($this->store->exists($id)); + } + + /** @return array */ + private function emptyMeta(): array { + return [ + 'baseRevision' => 0, 'nextRevision' => 1, 'writes' => [], + 'deletes' => [], 'assetRefs' => [], 'fixedRefs' => [], + ]; + } + + private function removeTree(string $dir): void { + if (!is_dir($dir)) { + @unlink($dir); + return; + } + foreach (scandir($dir) ?: [] as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $this->removeTree($dir . '/' . $item); + } + @rmdir($dir); + } +} diff --git a/tests/Unit/Service/Preview/PreviewSessionStoreTest.php b/tests/Unit/Service/Preview/PreviewSessionStoreTest.php new file mode 100644 index 0000000..9d2662c --- /dev/null +++ b/tests/Unit/Service/Preview/PreviewSessionStoreTest.php @@ -0,0 +1,382 @@ +root = sys_get_temp_dir() . '/exe-store-' . bin2hex(random_bytes(6)); + $this->distRoot = sys_get_temp_dir() . '/exe-store-dist-' . bin2hex(random_bytes(6)); + mkdir($this->distRoot . '/bundles', 0770, true); + mkdir($this->distRoot . '/libs/jquery', 0770, true); + } + + protected function tearDown(): void { + $this->removeTree($this->root); + $this->removeTree($this->distRoot); + } + + private function store(?PreviewSessionLimits $limits = null): PreviewSessionStore { + return new PreviewSessionStore( + $this->root, + $limits ?? new PreviewSessionLimits(), + fn (): int => $this->now, + ); + } + + /** Manifest with two fixed resources (a JS lib and a scriptable SVG). */ + private function fixedManifest(): FixedResourceManifest { + file_put_contents($this->distRoot . '/libs/jquery/jquery.min.js', 'window.jQuery=function(){};'); + file_put_contents( + $this->distRoot . '/libs/jquery/icon.svg', + '', + ); + file_put_contents($this->distRoot . '/bundles/preview-fixed-resources.json', json_encode([ + 'resources' => [ + 'libs/jquery/jquery.min.js' => ['path' => 'libs/jquery/jquery.min.js'], + 'theme:base/icon.svg' => ['path' => 'libs/jquery/icon.svg'], + ], + ])); + return new FixedResourceManifest($this->distRoot); + } + + /** An empty (disabled) fixed layer. */ + private function noFixed(): FixedResourceManifest { + return new FixedResourceManifest($this->distRoot . '/absent'); + } + + private function asset(string $key, string $bytes): array { + return ['key' => $key, 'declaredSize' => strlen($bytes), 'bytes' => $bytes]; + } + + // -- Lifecycle ----------------------------------------------------------- + + public function testCreateAndOwnership(): void { + $store = $this->store(); + $id = $store->create('alice'); + + self::assertTrue($store->exists($id)); + self::assertSame('alice', $store->ownerOf($id)); + self::assertSame(0, $store->activeRevision($id)); + self::assertMatchesRegularExpression( + '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', + $id, + ); + } + + public function testDeleteRemovesSession(): void { + $store = $this->store(); + $id = $store->create('alice'); + self::assertTrue($store->delete($id)); + self::assertFalse($store->exists($id)); + self::assertFalse($store->delete($id)); + self::assertNull($store->ownerOf($id)); + } + + public function testPerUserSessionCapEvictsLeastRecentlyUsed(): void { + $store = $this->store(new PreviewSessionLimits(maxSessionsPerUser: 2)); + $this->now = 1000; + $first = $store->create('alice'); + $this->now = 1001; + $second = $store->create('alice'); + $this->now = 1002; + $third = $store->create('alice'); + + self::assertFalse($store->exists($first), 'oldest owned session evicted'); + self::assertTrue($store->exists($second)); + self::assertTrue($store->exists($third)); + // A different user is unaffected by alice's cap. + $bob = $store->create('bob'); + self::assertTrue($store->exists($bob)); + } + + // -- Assets -------------------------------------------------------------- + + public function testStoreAssetsAndImmutability(): void { + $store = $this->store(); + $id = $store->create('alice'); + $key = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57'; + + $first = $store->storeAssets($id, [$this->asset($key, 'PHOTO-BYTES-v1')]); + self::assertSame([$key], $first['stored']); + self::assertSame([], $first['alreadyStored']); + + // Re-upload the SAME key with DIFFERENT bytes → alreadyStored, not replaced. + $second = $store->storeAssets($id, [$this->asset($key, 'PHOTO-BYTES-v2-DIFFERENT')]); + self::assertSame([], $second['stored']); + self::assertSame([$key], $second['alreadyStored']); + + // Publish a revision referencing it and prove the ORIGINAL bytes serve. + $this->publish($store, $id, 0, 1, [], ['content/photo.png' => $key], []); + $file = $store->resolve($id, 'content/photo.png', $this->noFixed()); + self::assertNotNull($file); + self::assertSame($key, $file['etag']); + self::assertSame('PHOTO-BYTES-v1', file_get_contents($file['filePath'])); + } + + public function testStoreAssetsRejections(): void { + $store = $this->store(new PreviewSessionLimits(maxAssetBytes: 8, maxBytesPerSession: 20)); + $id = $store->create('alice'); + $goodKey = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57'; + $bigKey = 'bbbbbbbb-cccc-4ddd-8eee-ffff00001111@0011223344'; + + $result = $store->storeAssets($id, [ + ['key' => 'not-a-valid-key', 'declaredSize' => 3, 'bytes' => 'abc'], + ['key' => $goodKey, 'declaredSize' => 99, 'bytes' => 'abc'], + $this->asset($bigKey, 'TOO-LONG-BYTES'), + ]); + + $reasons = []; + foreach ($result['rejected'] as $entry) { + $reasons[$entry['key']] = $entry['reason']; + } + self::assertSame('invalid-key', $reasons['not-a-valid-key']); + self::assertSame('size-mismatch', $reasons[$goodKey]); + self::assertSame('asset-too-large', $reasons[$bigKey]); + self::assertSame([], $result['stored']); + } + + // -- Revisions ----------------------------------------------------------- + + public function testPublishRevisionHappyPath(): void { + $store = $this->store(); + $fixed = $this->fixedManifest(); + $id = $store->create('alice'); + $key = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57'; + $store->storeAssets($id, [$this->asset($key, 'PHOTO')]); + + $result = $store->applyRevision($id, [ + 'baseRevision' => 0, + 'nextRevision' => 1, + 'writes' => [ + ['path' => 'index.html', 'bytes' => 'hi'], + ['path' => 'img/inline.svg', 'bytes' => ''], + ], + 'deletes' => [], + 'assetRefs' => ['content/photo.png' => $key], + 'fixedRefs' => ['libs/jquery/jquery.min.js' => 'libs/jquery/jquery.min.js'], + ], $fixed); + + self::assertSame(200, $result['status']); + self::assertSame(1, $result['revision']); + self::assertSame(1, $store->activeRevision($id)); + + $doc = $store->resolve($id, 'index.html', $fixed); + self::assertSame('document', $doc['kind']); + self::assertSame('hi', $doc['bytes']); + + $fixedFile = $store->resolve($id, 'libs/jquery/jquery.min.js', $fixed); + self::assertSame('fixed', $fixedFile['kind']); + self::assertSame('window.jQuery=function(){};', $fixedFile['bytes']); + } + + public function testStaleBaseRevisionConflicts(): void { + $store = $this->store(); + $id = $store->create('alice'); + $this->publish($store, $id, 0, 1, [['path' => 'index.html', 'bytes' => 'v1']], [], []); + + $result = $store->applyRevision($id, [ + 'baseRevision' => 0, + 'nextRevision' => 1, + 'writes' => [['path' => 'index.html', 'bytes' => 'stale']], + 'deletes' => [], + 'assetRefs' => [], + 'fixedRefs' => [], + ], $this->noFixed()); + + self::assertSame(409, $result['status']); + self::assertSame(1, $result['currentRevision']); + // The active revision is untouched by the rejected apply (atomicity). + self::assertSame('v1', $store->resolve($id, 'index.html', $this->noFixed())['bytes']); + } + + public function testUnsafeWritePathRejected(): void { + $store = $this->store(); + $id = $store->create('alice'); + $result = $store->applyRevision($id, [ + 'baseRevision' => 0, + 'nextRevision' => 1, + 'writes' => [['path' => '../escape.html', 'bytes' => 'x']], + 'deletes' => [], + 'assetRefs' => [], + 'fixedRefs' => [], + ], $this->noFixed()); + self::assertSame(400, $result['status']); + self::assertSame(0, $store->activeRevision($id)); + } + + public function testMissingAssetRefRejected(): void { + $store = $this->store(); + $id = $store->create('alice'); + $ghost = '99999999-9999-4999-8999-999999999999@deadbeef'; + $result = $store->applyRevision($id, [ + 'baseRevision' => 0, + 'nextRevision' => 1, + 'writes' => [], + 'deletes' => [], + 'assetRefs' => ['content/ghost.png' => $ghost], + 'fixedRefs' => [], + ], $this->noFixed()); + self::assertSame(422, $result['status']); + self::assertSame('missing-assets', $result['reason']); + self::assertSame([$ghost], $result['missing']); + } + + public function testUnknownFixedRefRejected(): void { + $store = $this->store(); + $id = $store->create('alice'); + $result = $store->applyRevision($id, [ + 'baseRevision' => 0, + 'nextRevision' => 1, + 'writes' => [], + 'deletes' => [], + 'assetRefs' => [], + 'fixedRefs' => ['theme/x.css' => 'theme:not-in-manifest'], + ], $this->noFixed()); + self::assertSame(422, $result['status']); + self::assertSame('unknown-fixed-resources', $result['reason']); + self::assertSame(['theme:not-in-manifest'], $result['resources']); + } + + public function testFileCountBudgetRejected(): void { + $store = $this->store(new PreviewSessionLimits(maxFilesPerSession: 1)); + $id = $store->create('alice'); + $result = $store->applyRevision($id, [ + 'baseRevision' => 0, + 'nextRevision' => 1, + 'writes' => [ + ['path' => 'a.html', 'bytes' => 'a'], + ['path' => 'b.html', 'bytes' => 'b'], + ], + 'deletes' => [], + 'assetRefs' => [], + 'fixedRefs' => [], + ], $this->noFixed()); + self::assertSame(413, $result['status']); + } + + public function testIncrementalDeltaAndDeletes(): void { + $store = $this->store(); + $id = $store->create('alice'); + $this->publish($store, $id, 0, 1, [ + ['path' => 'index.html', 'bytes' => 'home'], + ['path' => 'gone.html', 'bytes' => 'temp'], + ], [], []); + // Revision 2: rewrite index.html, delete gone.html. + $this->publish($store, $id, 1, 2, [['path' => 'index.html', 'bytes' => 'home-v2']], [], [], ['gone.html']); + + self::assertSame(2, $store->activeRevision($id)); + self::assertSame('home-v2', $store->resolve($id, 'index.html', $this->noFixed())['bytes']); + self::assertNull($store->resolve($id, 'gone.html', $this->noFixed())); + } + + // -- Resolution edge cases ---------------------------------------------- + + public function testResolveBeforeFirstRevisionIsNull(): void { + $store = $this->store(); + $id = $store->create('alice'); + self::assertNull($store->resolve($id, 'index.html', $this->noFixed())); + } + + public function testResolveUnknownPathIsNull(): void { + $store = $this->store(); + $id = $store->create('alice'); + $this->publish($store, $id, 0, 1, [['path' => 'index.html', 'bytes' => 'x']], [], []); + self::assertNull($store->resolve($id, 'nope.css', $this->noFixed())); + self::assertNull($store->resolve($id, '../escape', $this->noFixed())); + } + + public function testResolveOnMissingSessionIsNull(): void { + $store = $this->store(); + self::assertNull($store->resolve('3f2a1b4c-5d6e-4f70-8a90-b1c2d3e4f506', 'index.html', $this->noFixed())); + } + + // -- TTL ----------------------------------------------------------------- + + public function testExpiredSessionIsDroppedOnAccessAndSwept(): void { + $store = $this->store(new PreviewSessionLimits(ttlSeconds: 100)); + $this->now = 1000; + $id = $store->create('alice'); + $this->publish($store, $id, 0, 1, [['path' => 'index.html', 'bytes' => 'x']], [], []); + + // Within TTL: still served. + $this->now = 1050; + self::assertNotNull($store->resolve($id, 'index.html', $this->noFixed())); + + // Past TTL: opportunistically dropped on access. + $this->now = 2000; + self::assertNull($store->resolve($id, 'index.html', $this->noFixed())); + self::assertFalse($store->exists($id)); + } + + public function testSweepExpiredRemovesIdleSessions(): void { + $store = $this->store(new PreviewSessionLimits(ttlSeconds: 100)); + $this->now = 1000; + $stale = $store->create('alice'); + $this->now = 1900; + $fresh = $store->create('bob'); + + $this->now = 1950; // stale is 950s idle, fresh is 50s idle + self::assertSame(1, $store->sweepExpired()); + self::assertFalse($store->exists($stale)); + self::assertTrue($store->exists($fresh)); + } + + // -- Helpers ------------------------------------------------------------- + + /** + * @param list $writes + * @param array $assetRefs + * @param array $fixedRefs + * @param list $deletes + */ + private function publish( + PreviewSessionStore $store, + string $id, + int $base, + int $next, + array $writes, + array $assetRefs, + array $fixedRefs, + array $deletes = [], + ): void { + $result = $store->applyRevision($id, [ + 'baseRevision' => $base, + 'nextRevision' => $next, + 'writes' => $writes, + 'deletes' => $deletes, + 'assetRefs' => $assetRefs, + 'fixedRefs' => $fixedRefs, + ], $this->noFixed()); + self::assertSame(200, $result['status'], 'publish helper expects success'); + } + + private function removeTree(string $dir): void { + if (!is_dir($dir)) { + @unlink($dir); + return; + } + foreach (scandir($dir) ?: [] as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $this->removeTree($dir . '/' . $item); + } + @rmdir($dir); + } +} diff --git a/tests/fixtures/preview-contract/vectors.json b/tests/fixtures/preview-contract/vectors.json new file mode 100644 index 0000000..c98880d --- /dev/null +++ b/tests/fixtures/preview-contract/vectors.json @@ -0,0 +1,299 @@ +{ + "description": "Machine-readable conformance vectors for eXeLearning preview serving contract v2 (doc/development/preview-serving-contract.md). Replay every step, in order, against a host implementation. See README.md for harness semantics.", + "protocolVersion": 2, + "fixedResources": { + "libs/jquery/jquery.min.js": { + "path": "libs/jquery/jquery.min.js", + "content": "window.jQuery=function(){};" + }, + "theme:base/icon.svg": { + "path": "files/perm/themes/base/base/icon.svg", + "content": "" + } + }, + "constants": { + "photoKey": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57", + "clipKey": "12345678-90ab-4cde-8f01-234567890abc@00112233" + }, + "steps": [ + { + "id": "create-session", + "request": { "method": "POST", "path": "/api/preview-session" }, + "expect": { + "status": 201, + "body": { "protocolVersion": 2, "revision": 0 } + } + }, + { + "id": "upload-assets", + "request": { + "method": "POST", + "path": "/api/preview-session/{previewId}/assets", + "body": { + "kind": "assets", + "entries": [ + { "key": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57", "content": "PHOTO-BYTES-v1" }, + { "key": "12345678-90ab-4cde-8f01-234567890abc@00112233", "content": "0123456789" } + ] + } + }, + "expect": { + "status": 200, + "body": { + "stored": [ + "aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57", + "12345678-90ab-4cde-8f01-234567890abc@00112233" + ], + "alreadyStored": [], + "rejected": [] + } + } + }, + { + "id": "reupload-asset-immutability", + "comment": "Same key, DIFFERENT bytes: reported alreadyStored, bytes NOT replaced (asserted later by serve-asset).", + "request": { + "method": "POST", + "path": "/api/preview-session/{previewId}/assets", + "body": { + "kind": "assets", + "entries": [ + { + "key": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57", + "content": "PHOTO-BYTES-v2-DIFFERENT" + } + ] + } + }, + "expect": { + "status": 200, + "body": { + "stored": [], + "alreadyStored": ["aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57"], + "rejected": [] + } + } + }, + { + "id": "publish-revision-1", + "request": { + "method": "POST", + "path": "/api/preview-session/{previewId}/revisions", + "body": { + "kind": "revision", + "meta": { + "baseRevision": 0, + "nextRevision": 1, + "deletes": [], + "assetRefs": { + "content/resources/photo.png": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57", + "media/clip.mp4": "12345678-90ab-4cde-8f01-234567890abc@00112233" + }, + "fixedRefs": { + "libs/jquery/jquery.min.js": "libs/jquery/jquery.min.js", + "theme/icon.svg": "theme:base/icon.svg" + } + }, + "writes": [ + { "path": "index.html", "content": "conformance" }, + { + "path": "img/inline.svg", + "content": "" + } + ] + } + }, + "expect": { "status": 200, "body": { "revision": 1, "active": true } } + }, + { + "id": "serve-document", + "request": { "method": "GET", "path": "/preview/{previewId}/index.html" }, + "expect": { + "status": 200, + "bodyText": "conformance", + "headers": { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + "Access-Control-Allow-Origin": "*", + "Content-Security-Policy": { "startsWith": "sandbox allow-scripts allow-popups allow-forms" } + } + } + }, + { + "id": "serve-asset", + "comment": "Bytes are the ORIGINAL upload — proves reupload-asset-immutability did not replace them.", + "request": { "method": "GET", "path": "/preview/{previewId}/content/resources/photo.png" }, + "expect": { + "status": 200, + "bodyText": "PHOTO-BYTES-v1", + "headers": { + "Content-Type": "image/png", + "Cache-Control": "no-cache", + "ETag": "\"aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57\"", + "Accept-Ranges": "bytes", + "Content-Security-Policy": { "absent": true } + } + } + }, + { + "id": "serve-asset-304", + "request": { + "method": "GET", + "path": "/preview/{previewId}/content/resources/photo.png", + "headers": { "If-None-Match": "\"aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57\"" } + }, + "expect": { + "status": 304, + "headers": { "X-Content-Type-Options": "nosniff" } + } + }, + { + "id": "serve-asset-range", + "request": { + "method": "GET", + "path": "/preview/{previewId}/media/clip.mp4", + "headers": { "Range": "bytes=2-4" } + }, + "expect": { + "status": 206, + "bodyText": "234", + "headers": { "Content-Range": "bytes 2-4/10", "Content-Length": "3" } + } + }, + { + "id": "serve-asset-range-unsatisfiable", + "request": { + "method": "GET", + "path": "/preview/{previewId}/media/clip.mp4", + "headers": { "Range": "bytes=99-" } + }, + "expect": { + "status": 416, + "headers": { "Content-Range": "bytes */10" } + } + }, + { + "id": "serve-fixed", + "request": { "method": "GET", "path": "/preview/{previewId}/libs/jquery/jquery.min.js" }, + "expect": { + "status": 200, + "bodyText": "window.jQuery=function(){};", + "headers": { + "Cache-Control": "private, max-age=31536000", + "X-Content-Type-Options": "nosniff", + "Access-Control-Allow-Origin": "*" + } + } + }, + { + "id": "serve-fixed-scriptable-svg", + "comment": "The sandbox CSP must be emitted on scriptable types from EVERY layer, fixed included.", + "request": { "method": "GET", "path": "/preview/{previewId}/theme/icon.svg" }, + "expect": { + "status": 200, + "headers": { + "Content-Type": "image/svg+xml; charset=utf-8", + "Cache-Control": "private, max-age=31536000", + "Content-Security-Policy": { "startsWith": "sandbox allow-scripts allow-popups allow-forms" } + } + } + }, + { + "id": "serve-session-scriptable-svg", + "request": { "method": "GET", "path": "/preview/{previewId}/img/inline.svg" }, + "expect": { + "status": 200, + "headers": { + "Content-Type": "image/svg+xml; charset=utf-8", + "Content-Security-Policy": { "startsWith": "sandbox allow-scripts allow-popups allow-forms" } + } + } + }, + { + "id": "serve-unknown-404", + "request": { "method": "GET", "path": "/preview/{previewId}/nope.css" }, + "expect": { + "status": 404, + "headers": { + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + "Access-Control-Allow-Origin": "*" + } + } + }, + { + "id": "serve-traversal-raw", + "comment": "A literal ../ is normalized away by URL parsing before it reaches the server; whatever survives must still 404.", + "request": { "method": "GET", "path": "/preview/{previewId}/../secret" }, + "expect": { "status": 404 } + }, + { + "id": "serve-traversal-encoded", + "comment": "Percent-encoded traversal reaches the server verbatim and must be rejected by path normalization.", + "request": { "method": "GET", "path": "/preview/{previewId}/%2e%2e%2fsecret" }, + "expect": { "status": 404, "headers": { "Cache-Control": "no-store" } } + }, + { + "id": "conflicting-revision-409", + "comment": "Stale baseRevision (client believes 0, server is at 1).", + "request": { + "method": "POST", + "path": "/api/preview-session/{previewId}/revisions", + "body": { + "kind": "revision", + "meta": { + "baseRevision": 0, + "nextRevision": 1, + "deletes": [], + "assetRefs": {}, + "fixedRefs": {} + }, + "writes": [{ "path": "index.html", "content": "stale" }] + } + }, + "expect": { + "status": 409, + "body": { "reason": "revision-conflict", "currentRevision": 1 } + } + }, + { + "id": "missing-asset-revision-422", + "request": { + "method": "POST", + "path": "/api/preview-session/{previewId}/revisions", + "body": { + "kind": "revision", + "meta": { + "baseRevision": 1, + "nextRevision": 2, + "deletes": [], + "assetRefs": { + "content/resources/ghost.png": "99999999-9999-4999-8999-999999999999@deadbeef" + }, + "fixedRefs": {} + }, + "writes": [] + } + }, + "expect": { + "status": 422, + "body": { + "reason": "missing-assets", + "missing": ["99999999-9999-4999-8999-999999999999@deadbeef"] + } + } + }, + { + "id": "delete-session", + "request": { "method": "DELETE", "path": "/api/preview-session/{previewId}" }, + "expect": { "status": 200, "body": { "success": true } } + }, + { + "id": "serve-after-delete-404", + "request": { "method": "GET", "path": "/preview/{previewId}/index.html" }, + "expect": { "status": 404, "headers": { "Cache-Control": "no-store" } } + } + ] +} From db50d047815e99e5bcdae91220cafdf84e3e2af6 Mon Sep 17 00:00:00 2001 From: erseco Date: Sat, 11 Jul 2026 01:59:00 +0100 Subject: [PATCH 16/43] docs(preview): rewrite serving-contract mirror to v2 Replace the v1 (manifest + content-addressed blob) host-side companion with the v2 three-layer model: management API, authless serving route, tiered Cache-Control, the byte-identical sandbox CSP, the fixed-resource manifest, file-backed storage and cleanup. Document the app's two-CSP situation (PreviewPolicy preview CSP vs IframeSandbox published CSP) and why they are deliberately not unified. --- docs/preview-serving-contract.md | 292 ++++++++++++++++++++----------- 1 file changed, 188 insertions(+), 104 deletions(-) diff --git a/docs/preview-serving-contract.md b/docs/preview-serving-contract.md index 6b01ec7..7626642 100644 --- a/docs/preview-serving-contract.md +++ b/docs/preview-serving-contract.md @@ -1,132 +1,216 @@ -# Host-served opaque HTTP preview (serving contract) +# Host-served opaque HTTP preview — serving contract v2 -This Nextcloud app can act as a **preview host** for the eXeLearning editor: it -serves the *editor preview* of untrusted, in-progress author content over HTTP -from an **opaque origin**, isolated from the Nextcloud session. +This Nextcloud app is a **preview host** for the eXeLearning editor: it serves +the *editor preview* of untrusted, in-progress author content over HTTP from an +**opaque origin**, isolated from the Nextcloud session. This document is the host-side companion to the canonical contract in eXe core: **`doc/development/preview-serving-contract.md`** (repo `exelearning/exelearning`). -The core doc is authoritative. If the two ever disagree, core wins — in -particular the sandbox CSP string below must stay **byte-identical** to core. +Core is authoritative; if the two disagree, core wins — in particular the sandbox +CSP string must stay **byte-identical** to core's `previewCspHeader()`. + +**Protocol version: 2.** v1 (a full SHA-256 manifest + content-addressed blob +diff) is removed, not kept alongside. A create-session response without +`protocolVersion: 2` must surface an error (no silent fallback). ## Why an HTTP transport (and not the Service Worker) -For *published* `.elpx` content this app already serves package bytes two ways: -the in-browser Service Worker (`src/sw/exelearning-sw.js`) and the authenticated -fallback `AssetController::fetch` (`/asset/{sessionId}/{path}`). Both are -**same-origin** and are fine for content the owner has committed. +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. So the editor preview MUST be served from a **separate, authless, -cookieless origin** and never through the Service Worker. That is what this -contract provides. - -## What this reuses vs. what is new - -Reused idioms from the published-content path: -- The `DataDisplayResponse` + raw `addHeader(...)` serving idiom from - `lib/Controller/AssetController.php`. -- Path-safety via `ZipEntryService::normalizeEntry()` (rejects `..`, `.`, - absolute and NUL-tainted paths). -- The small extension→MIME map idiom from `AssetController::MIME_MAP`. - -New in this contract (follow-up implementation): -- A `#[PublicPage]` **authless** serving route (the app has none today). -- A **content-addressed, per-session store** (not a single `.elpx`). -- The **sandbox-first CSP** applied to scriptable document types. - -## Serving route (AUTHLESS capability URL) +APIs and pivot. A Service Worker **cannot** back an opaque origin (its +subresources bypass the SW). So the editor preview is served from a **separate, +authless, cookieless origin** and never through the Service Worker. + +## The three layers (why v2) + +v2 splits a preview into three layers with different lifecycles so a refresh +costs `O(changed documents + new assets)` instead of `O(whole project)`: + +| Layer | Contents | Lifecycle | Transferred | +|---|---|---|---| +| **Fixed installation resources** (1) | official libraries, base iDevice runtimes, base theme files, PDF.js, content CSS, logo, fonts | immutable per installed editor version | **never** — served from the installed static editor distribution, gated by a build manifest | +| **Session project assets** (2) | author images/audio/video/PDF — anything with an asset identity | immutable per `assetKey`, whole session | **once per session** | +| **Generated documents** (3) | page HTML, navigation, generated CSS/JS, user theme/iDevice files | change every edit | **only the changed files**, as an atomic revision delta | + +Classification is by **provenance, not name**: a resource is *fixed* only when the +client resolved it from the installation-immutable editor distribution. Custom +themes, user-installed iDevices and anything embedded in an `.elpx` ride the +session layers. + +## Implementation map + +All protocol logic is OCP-free and unit-testable; the controllers are thin +Nextcloud adapters. + +| Concern | Class | +|---|---| +| Isolation policy (CSP, scriptable set, Permissions-Policy, MIME, path normalization) | `lib/Service/Preview/PreviewPolicy.php` | +| Session store (three layers, atomic revisions, budgets, TTL, immutability) | `lib/Service/Preview/PreviewSessionStore.php` | +| Fixed-resource layer (manifest lookup + containment) | `lib/Service/Preview/FixedResourceManifest.php` | +| Serving HTTP policy (headers/CSP/cache tiers/Range/304) | `lib/Service/Preview/PreviewServer.php` | +| Management HTTP policy (ownership gate, status/body mapping) | `lib/Service/Preview/PreviewSessionApi.php` | +| Authless serving controller | `lib/Controller/PreviewController.php` | +| Authenticated management controller | `lib/Controller/PreviewSessionController.php` | +| Idle-TTL cleanup job | `lib/BackgroundJob/PreviewCleanupJob.php` | + +## A. Management API (AUTHENTICATED, owner-scoped) + +Served under the normal authenticated app routes (CSRF **on** — these are called +by the embedded editor same-origin, mirroring `editor#save`; they are **not** +`#[NoCSRFRequired]`). Ownership is scoped to the `IUserSession` user id. + +| Method & path | Body | Success | +|---|---|---| +| `POST /apps/exelearning/api/preview-session` | – | `201 { previewId, protocolVersion: 2, revision: 0, limits }` | +| `POST …/preview-session/{previewId}/assets` | multipart: `assets` (JSON `[{key,size}]`), `files[]` index-aligned | `200 { stored, alreadyStored, rejected }` | +| `POST …/preview-session/{previewId}/revisions` | multipart: `revision` (JSON, below), `files[]` aligned with `writes` | `200 { revision, active: true }` | +| `DELETE …/preview-session/{previewId}` | – | `200 { success: true }` | + +- **Asset keys** match `^[0-9a-fA-F-]{36}@[0-9a-f]{8,64}$` and are **immutable**: + re-uploading an existing key returns it in `alreadyStored` and never replaces + bytes (a replaced author file gets a new key). The server treats the key as an + opaque token — it never hashes asset bytes. Enforced on the buffered bytes; a + declared/actual size mismatch rejects that entry. +- **Revision JSON** (`writes` = paths, index-aligned with `files[]`): + + ```jsonc + { + "baseRevision": 17, // the revision the client believes is active + "nextRevision": 18, // must be baseRevision + 1 + "writes": ["index.html"], // aligned with files[] + "deletes": ["html/old.html"], + "assetRefs": { "content/photo.png": "3f2a…@9c41d2e8" }, // FULL map served path → assetKey + "fixedRefs": { "libs/jquery/jquery.min.js": "libs/jquery/jquery.min.js" } // FULL map served path → fixedResourceId + } + ``` + + Validation order: session exists (`404`) → `baseRevision`/`nextRevision` + consistent else `409 { reason: "revision-conflict", currentRevision }` → every + path normalized/safe else `400` → every `assetRefs` value stored else + `422 { reason: "missing-assets", missing }` → every `fixedRefs` value in the + fixed manifest else `422 { reason: "unknown-fixed-resources", resources }` → + file-count/byte budgets else `413`. +- **Atomicity.** A revision is published by writing its content-addressed blobs, + writing the revision manifest (temp+rename), then swapping the `current` + pointer (temp+rename). A concurrent `GET` reads `current` once then an + immutable manifest, so it observes revision *N* or *N+1*, never a mixture. +- **Budgets & TTL.** 30-min idle TTL, 4 sessions/user, 5000 files/session, 200 + MiB/session, 128 MiB/asset, 2 GiB global (per-user and global caps evict LRU). + +## B. Serving route (AUTHLESS capability URL) ``` -GET {previewBasePath}/preview/{previewId}/{path} +GET /apps/exelearning/preview/{previewId}/{path} ``` -- `previewId` MUST match - `^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`. - Anything else → **404** (with the hardening headers, see below). -- The bare `/preview/{previewId}/` resolves `path` to `index.html`. -- No auth, no CSRF, **no credentials** — the response carries - `Access-Control-Allow-Origin: *`, which is only sound because the origin is - cookieless. The route must never emit a Nextcloud session cookie. - -Reference implementation skeleton: **`lib/Controller/PreviewController.php`**. - -## Required response headers (on EVERY response, including 404) +- **Authless, cookieless.** The opaque iframe sends no SameSite cookies, so this + route does not depend on the session. It is gated solely on the unguessable + server-minted `previewId` UUID + idle TTL (Nextcloud's cookieless serving + primitive, `#[PublicPage]` + `#[NoCSRFRequired]`). It never emits a session + cookie and always answers `Access-Control-Allow-Origin: *` (sound only because + it is cookieless — never paired with credentials). +- `previewId` must match + `^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`; else 404. +- The bare `/preview/{previewId}` resolves `path` to `index.html`. +- **Resolution order** (exact-key against the active revision only): + `documents[path]` → `assets[assetRefs[path]]` → `fixed[fixedRefs[path]]` → 404. + A path only ever names a stored entry; only the server-controlled manifest + `path` reaches the filesystem, contained under the distribution root. +- **Range** on session assets: `Accept-Ranges: bytes`, single-range `206`, `416` + on unsatisfiable. **Conditional**: `ETag: ""`, `If-None-Match` → `304`. + +### Required response headers (on EVERY response, including 404) | Header | Value | | --- | --- | | `X-Content-Type-Options` | `nosniff` | | `Referrer-Policy` | `no-referrer` | -| `Cache-Control` | `no-store` | | `Permissions-Policy` | `camera=(), microphone=(), geolocation=(), payment=()` | -| `Access-Control-Allow-Origin` | `*` (never together with credentials) | -| `Content-Type` | the real MIME of the served blob | - -## Sandbox CSP (scriptable document types only) - -On every **scriptable** document type — `text/html`, `image/svg+xml`, -`application/xml`, `application/xhtml+xml` — add this `Content-Security-Policy` -header **verbatim** (byte-identical to eXe core): +| `Access-Control-Allow-Origin` | `*` (never with credentials) | +| `Content-Type` | the served file's real MIME (always set explicitly — Nextcloud serves unknown extensions as `text/plain`) | -``` -sandbox allow-scripts allow-popups allow-forms; default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; media-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self'; frame-src 'self' https://www.youtube-nocookie.com https://player.vimeo.com; child-src 'self' https://www.youtube-nocookie.com https://player.vimeo.com; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; -``` - -The leading `sandbox` directive drops the content into an opaque, unique origin; -the rest is defence-in-depth. Do **not** re-order, re-quote, or reformat it — -keep it in one shared constant so the published-content path and this preview -path stay identical. - -## Capability + lifecycle model - -- **Capability UUID.** Knowledge of `previewId` is the only bearer of access. - Generate a v4 UUID per session; treat it as a secret; never log it. -- **Idle TTL.** A session expires after **30 min** of inactivity; expired → - 404. Every serve refreshes the timer. -- **Caps (defaults).** 5000 files and 200 MiB per session; 2 GiB global; reject - writes past the cap. -- **Content-addressed store.** Blobs are keyed by SHA-256, **re-hashed - server-side** on upload; hash-mismatched blobs are quarantined, never served. - Manifest swaps are atomic. +`Cache-Control` is **tiered by layer**: -## Management API (AUTHENTICATED, owner-scoped) — follow-up - -Served under the normal authenticated app routes (NOT the public origin): - -- `POST /api/preview-session` → `{ previewId, previewBasePath, limits }` -- `POST /api/preview-session/{id}/manifest` `{files:{path:{sha256,size}}}` - → `{ manifestId, missing[], active }` -- `POST /api/preview-session/{id}/blobs` (multipart `manifestId`+`hashes`+files, - re-hash server-side) → `{ stored[], mismatched[], active }` -- `DELETE /api/preview-session/{id}` +| Response | Cache-Control | +| --- | --- | +| Generated document (layer 3) | `no-store` | +| Session asset (layer 2) | `no-cache` (+ `ETag`, `If-None-Match` → 304) | +| Fixed resource (layer 1) | `private, max-age=31536000` | +| 404 / errors | `no-store` | -## Editor activation +### Sandbox CSP (every scriptable document type, from every layer) -The editor turns this on by pointing its embedding config at this host: +On `text/html`, `image/svg+xml`, `application/xml`, `text/xml` and +`application/xhtml+xml` — whether the response resolves from the session or the +fixed layer — add this `Content-Security-Policy` **verbatim** (byte-identical to +eXe core `previewCspHeader()`; `PreviewPolicy::CSP`): -```js -window.__EXE_EMBEDDING_CONFIG__ = Object.assign(existingConfig, { - previewTransport: 'http', // never 'sw' for untrusted preview - previewBasePath: '/apps/exelearning', // absolute URL from IURLGenerator -}); +``` +sandbox allow-scripts allow-popups allow-forms; default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; media-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self'; frame-src 'self' https://www.youtube-nocookie.com https://player.vimeo.com; child-src 'self' https://www.youtube-nocookie.com https://player.vimeo.com; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'self' ``` -`previewBasePath` is returned by `POST /api/preview-session` -(`IURLGenerator::linkToRouteAbsolute('exelearning.preview.serve', …)`, trimmed to -the app root). The editor then loads `GET {previewBasePath}/preview/{previewId}/…` -inside its preview iframe. - -## Status / follow-up - -The serving endpoint in `lib/Controller/PreviewController.php` is a grounded -**skeleton**. Still to build (own PR, with tests): -1. `PreviewSessionStore` service (content-addressed store, caps, idle TTL, atomic - manifest swap). -2. `PreviewSessionController` (the authenticated management API above). -3. `SANDBOX_CSP` extracted into one shared constant, reused by the - published-content path. -4. PHPUnit coverage under `tests/Unit/Controller/PreviewControllerTest.php` - (UUID validation, header set incl. 404, CSP-on-scriptable-types) — patch - coverage ≥ 90%. \ No newline at end of file +The leading `sandbox` directive drops the document into an opaque, unique origin +even when opened top-level (a raw `*.html` **or** `*.svg` in a new tab stays +opaque — an author SVG served without it runs its inline `'; // The resilience shim must be installed before any editor script @@ -292,6 +298,56 @@ public function iframe(): DataDisplayResponse|DataResponse { return $response; } + /** + * The `previewHttp` block handed to the embedded editor so it can drive the + * HTTP editor-preview transport (serving contract v2). Shape frozen by the + * normalized contract: `{ protocolVersion, managementBaseUrl, servingBaseUrl, + * 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: Nextcloud rotates the CSRF token per session. The injected + * value stays valid for the whole editor session while the Nextcloud session + * is alive, but can go stale across a very long idle (session expiry). A future + * refresh path — the parent page re-issuing a CONFIGURE postMessage with a + * fresh token — is intentionally out of scope here. + * + * @return array{protocolVersion:int,managementBaseUrl:string,servingBaseUrl:string,managementHeaders:object} + */ + private function previewHttpConfig(): array { + $managementBaseUrl = $this->urlGenerator->linkToRoute(Application::APP_ID . '.previewSession.create'); + + // 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. + $sampleId = '00000000-0000-4000-8000-000000000000'; + $sampleUrl = $this->urlGenerator->linkToRoute( + Application::APP_ID . '.preview.serveRoot', + ['previewId' => $sampleId], + ); + $servingBaseUrl = str_ends_with($sampleUrl, '/' . $sampleId) + ? substr($sampleUrl, 0, -(strlen($sampleId) + 1)) + : $sampleUrl; + + return [ + 'protocolVersion' => 2, + 'managementBaseUrl' => $managementBaseUrl, + 'servingBaseUrl' => $servingBaseUrl, + '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 From 33dc623198778e0b531a51db9b05f889c2a7aaf9 Mon Sep 17 00:00:00 2001 From: erseco Date: Sat, 11 Jul 2026 12:40:34 +0100 Subject: [PATCH 22/43] fix(preview): correct serving Range and bare-root policy Two serving-policy corrections mandated by the normalized contract (v2.1 section 4), both in the OCP-free PreviewServer so they stay unit-testable and vector-replayable: - Range: a malformed, multi-range or non-bytes header is now IGNORED and the server returns a normal 200 full body. 416 is reserved for a syntactically valid single range that is unsatisfiable (e.g. bytes=99- past EOF). parseRange now distinguishes 'ignore' (null) from 'unsatisfiable' so the caller can tell the two apart; the old behaviour of 416-on-any-parse-failure was wrong. - Bare capability root: GET {servingBase}/{previewId} now 302-redirects to {previewId}/index.html instead of serving index.html bytes inline. Served inline, the document's relative subresource references resolve against preview/ (dropping the id segment) and every asset 404s. The Location is relative so it is correct under any webroot. --- lib/Controller/PreviewController.php | 8 +- lib/Service/Preview/PreviewServer.php | 80 ++++++++++++++----- .../Service/Preview/PreviewServerTest.php | 45 +++++++++++ 3 files changed, 108 insertions(+), 25 deletions(-) diff --git a/lib/Controller/PreviewController.php b/lib/Controller/PreviewController.php index 3c9bfb6..aa7e765 100644 --- a/lib/Controller/PreviewController.php +++ b/lib/Controller/PreviewController.php @@ -58,13 +58,15 @@ public function serve(string $previewId, string $path = 'index.html'): DataDispl /** * GET /apps/exelearning/preview/{previewId} * - * The bare capability root resolves to `index.html` (the `.+` path route - * above cannot match an empty segment). + * 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->serve($previewId, 'index.html'); + return $this->toDataDisplay($this->server->serveRoot($previewId)); } /** Map the framework-agnostic response onto a DataDisplayResponse. */ diff --git a/lib/Service/Preview/PreviewServer.php b/lib/Service/Preview/PreviewServer.php index 9b2c5fc..d7ff308 100644 --- a/lib/Service/Preview/PreviewServer.php +++ b/lib/Service/Preview/PreviewServer.php @@ -47,6 +47,30 @@ public function serve(string $previewId, string $rawPath, ?string $ifNoneMatch = }; } + /** + * Bare capability root (`GET {servingBase}/{previewId}`) → 302 redirect to + * `{previewId}/index.html`. + * + * The bare root must NEVER emit index.html bytes: a document served from the + * bare URL resolves its relative subresource references against + * `{servingBase}/` — dropping the `{previewId}` segment — so every asset, + * script and stylesheet 404s. Redirecting to `{previewId}/index.html` rebases + * them onto the correct directory. The `Location` is relative so it stays + * correct under any Nextcloud webroot (it resolves against the request URI, + * `{servingBase}/{previewId}`) without the store needing a URL generator, and + * the whole decision stays OCP-free and vector-replayable. Contract v2.1 §4. + */ + public function serveRoot(string $previewId): PreviewResponse { + if (!PreviewPolicy::isValidPreviewId($previewId)) { + return $this->notFound(); + } + $headers = $this->baseHeaders(); + $headers['Content-Type'] = 'text/plain; charset=utf-8'; + $headers['Cache-Control'] = 'no-store'; + $headers['Location'] = $previewId . '/index.html'; + return new PreviewResponse(302, $headers, ''); + } + /** * Document (layer 3, `no-store`) or fixed resource (layer 1, immutable and * cacheable). Both may be scriptable and then carry the sandbox CSP. @@ -80,18 +104,22 @@ private function serveAsset(array $file, ?string $ifNoneMatch, ?string $range): } $size = $file['size']; - if ($range !== null && trim($range) !== '') { - $parsed = $this->parseRange($range, $size); - if ($parsed === null) { - $headers = $this->baseHeaders(); - $headers['Content-Type'] = $file['contentType']; - $headers['Cache-Control'] = 'no-cache'; - $headers['ETag'] = $etag; - $headers['Accept-Ranges'] = 'bytes'; - $headers['Content-Range'] = 'bytes */' . $size; - return new PreviewResponse(416, $headers, ''); - } - [$start, $end] = $parsed; + $parsed = ($range !== null && trim($range) !== '') ? $this->parseRange($range, $size) : null; + if ($parsed !== null && $parsed['satisfiable'] === false) { + // A syntactically valid single range wholly outside the entity (e.g. + // `bytes=99-` on a 10-byte body) is unsatisfiable → 416. A malformed, + // multi-range or non-bytes header is IGNORED by parseRange (null) and + // falls through to the normal 200 full body below. + $headers = $this->baseHeaders(); + $headers['Content-Type'] = $file['contentType']; + $headers['Cache-Control'] = 'no-cache'; + $headers['ETag'] = $etag; + $headers['Accept-Ranges'] = 'bytes'; + $headers['Content-Range'] = 'bytes */' . $size; + return new PreviewResponse(416, $headers, ''); + } + if ($parsed !== null) { + [$start, $end] = [$parsed['start'], $parsed['end']]; $length = $end - $start + 1; $headers = $this->baseHeaders(); $headers['Content-Type'] = $file['contentType']; @@ -142,25 +170,33 @@ private function baseHeaders(): array { } /** - * Parse a single HTTP byte range against a known size. Returns `[start, end]` - * (inclusive) for a satisfiable range, or null for an unsatisfiable one - * (caller emits 416). A syntactically malformed header also yields null so it - * is treated as unsatisfiable rather than silently serving the whole body. + * Parse a single HTTP byte range against a known size, distinguishing the two + * outcomes the serving contract treats differently: + * + * - `null` — the header is malformed, a multi-range set, or a non-`bytes` + * unit. Per the contract it is IGNORED: the caller serves a normal 200 + * full body (never 416). This is the corrected behaviour — treating a + * header we don't understand as unsatisfiable was wrong. + * - `['satisfiable' => false]` — a syntactically valid single range that is + * unsatisfiable (first-byte-pos at/after EOF, an empty suffix, or an empty + * span). The caller emits 416. + * - `['satisfiable' => true, 'start' => int, 'end' => int]` — a satisfiable + * single range (inclusive). The caller emits 206. * - * @return array{0:int,1:int}|null + * @return array{satisfiable:bool,start?:int,end?:int}|null */ private function parseRange(string $range, int $size): ?array { if (preg_match('/^bytes=(\d*)-(\d*)$/', trim($range), $m) !== 1) { - return null; + return null; // malformed / multi-range / non-bytes unit → ignore (200) } [$rawStart, $rawEnd] = [$m[1], $m[2]]; if ($rawStart === '' && $rawEnd === '') { - return null; + return null; // `bytes=-` carries no range → ignore (200) } if ($rawStart === '') { $suffix = (int)$rawEnd; if ($suffix <= 0 || $size === 0) { - return null; + return ['satisfiable' => false]; } $start = max(0, $size - $suffix); $end = $size - 1; @@ -169,9 +205,9 @@ private function parseRange(string $range, int $size): ?array { $end = $rawEnd === '' ? $size - 1 : min((int)$rawEnd, $size - 1); } if ($start < 0 || $start >= $size || $start > $end) { - return null; + return ['satisfiable' => false]; } - return [$start, $end]; + return ['satisfiable' => true, 'start' => $start, 'end' => $end]; } /** Read $length bytes from $filePath starting at $start. */ diff --git a/tests/Unit/Service/Preview/PreviewServerTest.php b/tests/Unit/Service/Preview/PreviewServerTest.php index 9fd963e..9ab356e 100644 --- a/tests/Unit/Service/Preview/PreviewServerTest.php +++ b/tests/Unit/Service/Preview/PreviewServerTest.php @@ -132,6 +132,51 @@ public function testUnsatisfiableRangeReturns416(): void { self::assertSame('bytes */10', $response->headers['Content-Range']); } + /** + * A malformed, multi-range or non-bytes Range header is IGNORED — the server + * serves a normal 200 full body, never 416. (The previous behaviour of 416 + * on any parse failure was wrong per the serving contract.) + * + * @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::assertSame('"' . self::CLIP_KEY . '"', $response->headers['ETag']); + 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'], + ]; + } + + 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); + } + public function testServeFixedResourceIsAggressivelyCacheable(): void { $response = $this->server()->serve($this->previewId, 'libs/jquery/jquery.min.js'); self::assertSame(200, $response->status); From b61305d204ef41d09f2c08c8ee91628964ccd462 Mon Sep 17 00:00:00 2001 From: erseco Date: Sat, 11 Jul 2026 12:41:11 +0100 Subject: [PATCH 23/43] fix(preview): reclaim ghost sessions with a lost .accessed marker A missing/unreadable .accessed marker made isExpired() return false, so the session became immortal: it counted against the global byte budget forever and sweepExpired could never reclaim it. Fall back to meta.json createdAt as the age clock when the marker is gone; if meta.json is missing/corrupt too, the directory is unusable and is treated as expired so the sweep reclaims it. globalBytes() is recomputed from the surviving session directories on every call, so removing the directory reconciles the accounting automatically. Tests: a session whose .accessed was deleted is swept after TTL via the createdAt fallback; a directory with neither marker nor meta.json is reclaimed. --- lib/Service/Preview/PreviewSessionStore.php | 27 ++++++++++++-- .../Preview/PreviewSessionStoreTest.php | 37 +++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/lib/Service/Preview/PreviewSessionStore.php b/lib/Service/Preview/PreviewSessionStore.php index 9ae0d9b..9d5ff76 100644 --- a/lib/Service/Preview/PreviewSessionStore.php +++ b/lib/Service/Preview/PreviewSessionStore.php @@ -146,10 +146,22 @@ public function isExpired(string $previewId): bool { $marker = $this->sessionDir($previewId) . '/.accessed'; clearstatcache(true, $marker); $accessed = @filemtime($marker); - if ($accessed === false) { - return false; + if ($accessed !== false) { + return (($this->clock)() - $accessed) > $this->limits->ttlSeconds; + } + // A missing/unreadable `.accessed` marker must NOT make a session + // immortal — that would keep it counting against the global byte budget + // forever and never let sweepExpired reclaim it. Fall back to meta.json + // createdAt as the age clock; if meta.json is missing/corrupt too, the + // directory is unusable (it can neither be owned nor served), so treat it + // as expired and let sweepExpired reclaim it. globalBytes() is recomputed + // from the surviving session directories on every call, so removing the + // directory automatically reconciles the accounting. + $createdAt = $this->createdAt($previewId); + if ($createdAt === null) { + return true; } - return (($this->clock)() - $accessed) > $this->limits->ttlSeconds; + return (($this->clock)() - $createdAt) > $this->limits->ttlSeconds; } /** @@ -495,6 +507,15 @@ private function readMeta(string $previewId): ?array { return is_array($decoded) ? $decoded : null; } + /** The session creation unix time from meta.json, or null when missing/corrupt. */ + private function createdAt(string $previewId): ?int { + $meta = $this->readMeta($previewId); + if ($meta === null || !isset($meta['createdAt']) || !is_numeric($meta['createdAt'])) { + return null; + } + return (int)$meta['createdAt']; + } + /** * Read a revision manifest with its inner maps decoded as associative * arrays. diff --git a/tests/Unit/Service/Preview/PreviewSessionStoreTest.php b/tests/Unit/Service/Preview/PreviewSessionStoreTest.php index 5879f21..fc10a30 100644 --- a/tests/Unit/Service/Preview/PreviewSessionStoreTest.php +++ b/tests/Unit/Service/Preview/PreviewSessionStoreTest.php @@ -427,6 +427,43 @@ public function testSweepExpiredRemovesIdleSessions(): void { self::assertTrue($store->exists($fresh)); } + public function testMissingAccessedMarkerFallsBackToCreatedAtForTtl(): void { + // A lost `.accessed` marker (crash between touch and fsync, manual fs + // surgery, backup restore) must NOT make a session immortal: the age + // clock falls back to meta.json createdAt so the session still expires. + $store = $this->store(new PreviewSessionLimits(ttlSeconds: 100)); + $this->now = 1000; + $id = $store->create('alice'); + $this->publish($store, $id, 0, 1, [['path' => 'index.html', 'bytes' => str_repeat('x', 128)]], [], []); + unlink($this->root . '/' . $id . '/.accessed'); + + // Within TTL of createdAt (1000): not yet expired. + $this->now = 1050; + self::assertSame(0, $store->sweepExpired()); + self::assertTrue($store->exists($id)); + + // Past TTL of createdAt: swept, and the directory is physically gone — so + // its bytes no longer contribute to the recomputed global byte budget. + $this->now = 1200; + self::assertSame(1, $store->sweepExpired()); + self::assertFalse($store->exists($id)); + self::assertDirectoryDoesNotExist($this->root . '/' . $id); + } + + public function testCorruptSessionDirectoryIsReclaimed(): void { + // A directory that looks like a session id but has neither `.accessed` + // nor a readable meta.json can never be owned or served — it must be + // reclaimed rather than lingering forever. + $store = $this->store(new PreviewSessionLimits(ttlSeconds: 100)); + $this->now = 1000; + $corruptId = '3f2a1b4c-5d6e-4f70-8a90-b1c2d3e4f506'; + mkdir($this->root . '/' . $corruptId, 0770, true); + + self::assertTrue($store->isExpired($corruptId)); + self::assertSame(1, $store->sweepExpired()); + self::assertDirectoryDoesNotExist($this->root . '/' . $corruptId); + } + // -- Helpers ------------------------------------------------------------- /** From 436d9d7e4de5ba6930b7eb44678dede6f90eaeed Mon Sep 17 00:00:00 2001 From: erseco Date: Sat, 11 Jul 2026 12:41:31 +0100 Subject: [PATCH 24/43] fix(preview): reject revisions with a dropped or failed multipart part MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dropped/failed files[] part became a silently-empty document (sha1(''), size 0) and the revision published anyway. The controller now aligns every declared write with a healthy uploaded part (present, UPLOAD_ERR_OK, readable); any missing/failed/unreadable part — or a part/write count mismatch — rejects the whole batch with 400 before any staging, so the revision pointer never advances. uploadedFileParts() carries per-part upload health (a genuinely empty but successfully uploaded file stays valid); uploadedFileBytes() keeps the asset path unchanged by delegating to it. The store mirrors this rigor: a genuine document blob-write failure aborts applyRevision with 500 before the manifest write and pointer swap, so the active revision is never advanced onto a missing blob (an 'already present' content-addressed blob is not a failure). PreviewSessionApi maps 500 through. Test: a document write failure returns 500 and leaves the previous revision active. --- lib/Controller/PreviewSessionController.php | 73 ++++++++++++++++--- lib/Service/Preview/PreviewSessionApi.php | 1 + lib/Service/Preview/PreviewSessionStore.php | 19 ++++- .../Preview/PreviewSessionStoreTest.php | 29 ++++++++ 4 files changed, 108 insertions(+), 14 deletions(-) diff --git a/lib/Controller/PreviewSessionController.php b/lib/Controller/PreviewSessionController.php index 0e8202a..bd69bd7 100644 --- a/lib/Controller/PreviewSessionController.php +++ b/lib/Controller/PreviewSessionController.php @@ -88,11 +88,31 @@ public function publishRevision(string $previewId): DataResponse { return $this->unauthenticated(); } $revision = $this->decodeJsonParam('revision'); - $files = $this->uploadedFileBytes('files'); - $writePaths = is_array($revision['writes'] ?? null) ? $revision['writes'] : []; + $parts = $this->uploadedFileParts('files'); + $writePaths = is_array($revision['writes'] ?? null) ? array_values($revision['writes']) : []; + + // Strict alignment: every declared write MUST have a healthy uploaded part + // (present, UPLOAD_ERR_OK, readable). A dropped/failed/unreadable part — or + // a part/write count mismatch — rejects the ENTIRE batch with 400 before + // any staging, so a silently-empty document (`sha1('')`, size 0) can never + // be published. The revision pointer is untouched; a subsequent GET keeps + // serving the previous revision. + if (count($parts) !== count($writePaths)) { + return new DataResponse( + ['error' => 'Revision upload incomplete: ' . count($writePaths) + . ' write(s) declared but ' . count($parts) . ' file part(s) received'], + Http::STATUS_BAD_REQUEST, + ); + } $writes = []; - foreach (array_values($writePaths) as $index => $path) { - $writes[] = ['path' => (string)$path, 'bytes' => $files[$index] ?? '']; + foreach ($writePaths as $index => $path) { + if (!$parts[$index]['ok']) { + return new DataResponse( + ['error' => 'Revision upload incomplete: missing or unreadable file part for write #' . $index], + Http::STATUS_BAD_REQUEST, + ); + } + $writes[] = ['path' => (string)$path, 'bytes' => $parts[$index]['bytes']]; } $meta = [ 'baseRevision' => (int)($revision['baseRevision'] ?? -1), @@ -143,29 +163,58 @@ private function decodeJsonParam(string $name): array { /** * Read the `files[]` multipart parts (index-aligned) into a list of byte - * strings. Handles both the array shape (`files[]`, multiple parts) and the - * scalar shape (a single part). + * strings, mapping a missing/failed/unreadable part to an empty string. Used + * by the asset path, where the store rejects a dropped part via its + * declared/actual size guard. * * @return list */ private function uploadedFileBytes(string $field): array { + return array_map( + static fn (array $part): string => $part['bytes'], + $this->uploadedFileParts($field), + ); + } + + /** + * Read the `files[]` multipart parts (index-aligned) with their upload + * health, so a caller can reject a batch when a part is missing, errored or + * unreadable. Handles both the array shape (`files[]`, multiple parts) and the + * scalar shape (a single part). + * + * A part is `ok` only when `$_FILES[...]['error']` is `UPLOAD_ERR_OK`, the + * `tmp_name` is a genuine uploaded file, and its bytes read back. A genuinely + * empty but successfully uploaded file is `ok` with empty bytes; a + * dropped/failed/unreadable part is `ok=false` — the distinction the revision + * path needs and that a plain byte string cannot carry. + * + * @return list + */ + private function uploadedFileParts(string $field): array { $uploaded = $this->request->getUploadedFile($field); if (!is_array($uploaded) || !isset($uploaded['tmp_name'])) { return []; } $tmpNames = $uploaded['tmp_name']; + $errors = $uploaded['error'] ?? UPLOAD_ERR_NO_FILE; if (!is_array($tmpNames)) { $tmpNames = [$tmpNames]; + $errors = [$errors]; } - $bytes = []; - foreach ($tmpNames as $tmpName) { - if (!is_string($tmpName) || $tmpName === '' || !is_uploaded_file($tmpName)) { - $bytes[] = ''; + $parts = []; + foreach ($tmpNames as $index => $tmpName) { + $error = is_array($errors) ? ($errors[$index] ?? UPLOAD_ERR_NO_FILE) : $errors; + if ((int)$error !== UPLOAD_ERR_OK || !is_string($tmpName) || $tmpName === '' || !is_uploaded_file($tmpName)) { + $parts[] = ['ok' => false, 'bytes' => '']; continue; } $content = file_get_contents($tmpName); - $bytes[] = $content === false ? '' : $content; + if ($content === false) { + $parts[] = ['ok' => false, 'bytes' => '']; + continue; + } + $parts[] = ['ok' => true, 'bytes' => $content]; } - return $bytes; + return $parts; } } diff --git a/lib/Service/Preview/PreviewSessionApi.php b/lib/Service/Preview/PreviewSessionApi.php index 232edd7..0c5abb0 100644 --- a/lib/Service/Preview/PreviewSessionApi.php +++ b/lib/Service/Preview/PreviewSessionApi.php @@ -69,6 +69,7 @@ public function publishRevision(string $previewId, string $ownerUserId, array $m 409 => new ApiResult(409, ['reason' => 'revision-conflict', 'currentRevision' => $result['currentRevision']]), 422 => new ApiResult(422, $this->unprocessableBody($result)), 413 => new ApiResult(413, ['error' => $result['message'] ?? 'Preview storage budget exceeded']), + 500 => new ApiResult(500, ['error' => $result['message'] ?? 'Failed to persist revision']), default => new ApiResult(400, ['error' => $result['message'] ?? 'Bad request']), }; } diff --git a/lib/Service/Preview/PreviewSessionStore.php b/lib/Service/Preview/PreviewSessionStore.php index 9d5ff76..257fc62 100644 --- a/lib/Service/Preview/PreviewSessionStore.php +++ b/lib/Service/Preview/PreviewSessionStore.php @@ -275,6 +275,9 @@ public function storeAssets(string $previewId, array $entries): array { * budgets (413). The publish itself is an atomic blob-write → manifest-write * → pointer-swap under an advisory lock. * + * A genuine document blob-write failure aborts the publish with `500` before + * the pointer swap, leaving the previously active revision intact. + * * @param array{baseRevision:int,nextRevision:int,writes:list,deletes:list,assetRefs:array,fixedRefs:array} $meta * @return array{status:int,revision?:int,currentRevision?:int,reason?:string,missing?:list,resources?:list,message?:string} */ @@ -377,9 +380,21 @@ public function applyRevision(string $previewId, array $meta, FixedResourceManif } // 6. Publish. Write new content-addressed blobs, then the revision - // manifest, then atomically swap the pointer. + // manifest, then atomically swap the pointer. A genuine blob-write + // failure (disk full / unwritable) must abort BEFORE the manifest + // and pointer swap, or the active revision advances onto a document + // whose bytes are absent (a silent permanent 404). This mirrors the + // asset path's write-failed rigor. An "already present" blob is not + // a failure — content-addressing dedups identical bytes. foreach ($writes as $bytes) { - $this->writeBlobAtomic($dir . '/documents', sha1($bytes), $bytes); + $hash = sha1($bytes); + $target = $dir . '/documents/' . $hash; + if (!$this->writeBlobAtomic($dir . '/documents', $hash, $bytes)) { + clearstatcache(true, $target); + if (!is_file($target)) { + return ['status' => 500, 'message' => 'Failed to persist revision document']; + } + } } $this->writeFileAtomic($dir . '/revisions/' . $next . '.json', json_encode([ 'assetRefs' => (object)$assetRefs, diff --git a/tests/Unit/Service/Preview/PreviewSessionStoreTest.php b/tests/Unit/Service/Preview/PreviewSessionStoreTest.php index fc10a30..44902bd 100644 --- a/tests/Unit/Service/Preview/PreviewSessionStoreTest.php +++ b/tests/Unit/Service/Preview/PreviewSessionStoreTest.php @@ -309,6 +309,35 @@ public function testFileCountBudgetRejected(): void { self::assertSame(413, $result['status']); } + public function testRevisionDocumentWriteFailureAbortsBeforePointerSwap(): void { + $store = $this->store(); + $id = $store->create('alice'); + // Establish revision 1. + $this->publish($store, $id, 0, 1, [['path' => 'index.html', 'bytes' => 'v1']], [], []); + + // Force the next document blob write to fail deterministically: replace + // the documents/ directory with a regular file so any write under it + // fails with ENOTDIR. + $documentsPath = $this->root . '/' . $id . '/documents'; + $this->removeTree($documentsPath); + file_put_contents($documentsPath, 'not-a-directory'); + + $result = $store->applyRevision($id, [ + 'baseRevision' => 1, + 'nextRevision' => 2, + 'writes' => [['path' => 'index.html', 'bytes' => 'v2-NEW-UNIQUE-BYTES']], + 'deletes' => [], + 'assetRefs' => [], + 'fixedRefs' => [], + ], $this->noFixed()); + + // The publish aborts with 500 and never advances the pointer, so the + // previously active revision stays intact (no silent 404 document). + self::assertSame(500, $result['status']); + self::assertSame(1, $store->activeRevision($id)); + self::assertFileDoesNotExist($this->root . '/' . $id . '/revisions/2.json'); + } + public function testSupersededRevisionsArePrunedToBoundDisk(): void { $store = $this->store(); $id = $store->create('alice'); From 38f794ca087f5dd5b15c7a27ba4027a85d1ba407 Mon Sep 17 00:00:00 2001 From: erseco Date: Sat, 11 Jul 2026 12:41:43 +0100 Subject: [PATCH 25/43] docs(preview): normalize serving-contract vocab; correct viewer descriptions - docs/preview-serving-contract.md: replace the dead previewTransport / previewBasePath vocabulary with the normalized previewHttp two-URL model, document that editor activation is now wired plus its editor-build dependency, and note the bare-root 302 and malformed-Range->200 CHANGES with a vectors re-vendor-pending caveat (vectors are not forked here). - README.md + appinfo/info.xml: the viewer is served over the opaque /content HTTP route by default (a sandbox without allow-same-origin), not purely the Service Worker; note the HTTP editor-preview path and that only the legacy opt-in viewer needs Service Workers. - CHANGELOG.md: correct the stale claim that CI 'verifies the Service Worker route responds' to what CI actually does. --- CHANGELOG.md | 5 ++- README.md | 29 +++++++++---- appinfo/info.xml | 11 ++++- docs/preview-serving-contract.md | 74 ++++++++++++++++++++++++++++---- 4 files changed, 100 insertions(+), 19 deletions(-) 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/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 92f707d..d5c65f8 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -6,11 +6,18 @@ Preview and edit eXeLearning .elpx packages in Nextcloud "`, `If-None-Match` → `304`. +- **Range** on session assets: `Accept-Ranges: bytes`, a satisfiable single + range → `206`, a syntactically valid but unsatisfiable single range (e.g. + `bytes=99-` past EOF) → `416`. A malformed, multi-range or non-`bytes` header + is **ignored** — the server serves a normal `200` full body. **Conditional**: + `ETag: ""`, `If-None-Match` → `304`. ### Required response headers (on EVERY response, including 404) @@ -200,12 +207,49 @@ be object storage) — the only OCP seam; the store itself is OCP-free. route also drops an expired session opportunistically on access, so preview URLs stop resolving at the TTL even if cron is starved. -## Editor activation (not wired here) +## Editor activation (wired) -Turning the preview on is a client-side change (the editor points its embedding -config at `previewTransport: 'http'` + this host's `previewBasePath`). That -wiring is intentionally **not** part of this change — this PR delivers only the -host server side of the contract. +The embedded editor is handed a `previewHttp` block in +`window.__EXE_EMBEDDING_CONFIG__` by `EditorController::iframe()` +(`previewHttpConfig()`), following the normalized HTTP preview contract: + +```jsonc +{ + "previewHttp": { + "protocolVersion": 2, + "managementBaseUrl": "/apps/exelearning/api/preview-session", + "servingBaseUrl": "/apps/exelearning/preview", + "managementHeaders": { "requesttoken": "" } + } +} +``` + +- Both URLs are generated **server-side** through `IURLGenerator::linkToRoute`, + 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 `servingBaseUrl` is derived by generating the bare + capability-root URL for a placeholder id and stripping the id segment. +- `managementHeaders.requesttoken` is the **current Nextcloud CSRF token** — the + same encrypted value the standard template layer exposes as `data-requesttoken` + (obtained from `CsrfTokenManager::getToken()->getEncryptedValue()`). It is + required because the management routes keep CSRF **on** (they are **not** + `#[NoCSRFRequired]`); the editor replays it on every management request. + Nextcloud rotates the token per session — the injected value is valid for the + editor session while the Nextcloud session is alive, and can go stale only + across a very long idle (session expiry). A refresh path (the parent re-issuing + a CONFIGURE postMessage with a fresh token) is a future enhancement, not built + here. +- `previewTransport` / `previewBasePath` are **dead vocabulary** — the earlier + single-`previewBasePath` idea was never implemented and the two-URL + `previewHttp` model replaces it. + +**Editor-build dependency.** The endpoints are live, but preview only lights up +once the app ships a capable editor build — one that carries the +`HttpPreviewProvider` and emits `bundles/preview-fixed-resources.json` under +`js/editor/`. The bundled v4.0.2 editor predates the HTTP preview client, so it +simply ignores `previewHttp` today and the endpoints stay dormant (harmless). No +server change is needed when that build lands; only `make download-editor` points +at a newer distribution. ## Conformance @@ -214,3 +258,17 @@ Beyond the CSP drift-check, the shared vectors in are replayed against this app's seams in `tests/Unit/Service/Preview/PreviewContractConformanceTest.php`, so protocol semantics — not just the CSP string — stay aligned with every other host. + +**Vectors re-vendor pending.** The vendored `vectors.json` predates the two +contract CHANGES above (bare-root `302` and the malformed-Range→`200` rule). It is +**not** forked here — core re-vendors it once its own vectors carry these steps, +and this app re-syncs then. The new behaviours are covered directly by +`PreviewServerTest` in the meantime. + +The API-level end-to-end job in [`ci.yml`](../.github/workflows/ci.yml) exercises +the real management + serving surface against a live Nextcloud: it proves CSRF is +enforced on management (401/412 without a `requesttoken`), cross-user ownership is +denied (403), an asset upload + revision publish round-trips, the serving response +carries the `sandbox` CSP with no `allow-same-origin`, a dropped multipart part is +rejected (400) without advancing the revision, and a deleted session stops serving +(404). From 5b0c92e6f14a58fa2e41a389bc28793e7f19107f Mon Sep 17 00:00:00 2001 From: erseco Date: Sat, 11 Jul 2026 12:41:55 +0100 Subject: [PATCH 26/43] test(preview): API-level end-to-end against a real Nextcloud Add a CI job that boots a real Nextcloud, enables the app, serves it over HTTP and drives the editor-preview management and serving routes end to end (tests/e2e/preview-api-e2e.sh). Cookie/session auth is used on purpose so the CSRF middleware is genuinely exercised. It asserts: management rejects a create without a requesttoken (412, CSRF proof); create returns 201 with protocolVersion 2; asset upload + revision publish round-trip; the authless serving response is 200 with the opaque sandbox CSP and no allow-same-origin; the bare root 302-redirects; a second user's management call is 403; a dropped multipart part is rejected (400) without advancing the revision; and after DELETE the serving route 404s. Full editor-iframe browser E2E stays blocked on a capable editor build and is documented as such rather than faked. --- .github/workflows/ci.yml | 79 ++++++++++++++++++++++ tests/e2e/preview-api-e2e.sh | 125 +++++++++++++++++++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100755 tests/e2e/preview-api-e2e.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc2200c..5231256 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -177,3 +177,82 @@ 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 + php occ config:system:set overwrite.cli.url --value="http://127.0.0.1:8080/index.php" + + 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 + + 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/index.php/status.php" >/dev/null; then ready=1; break; fi + sleep 1 + done + [ "$ready" = "1" ] || { echo "Nextcloud did not come up"; 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 ---'; php occ log:tail --num 60 || true; exit 1; } + + kill "$(cat "${RUNNER_TEMP}/nc-server.pid")" 2>/dev/null || true diff --git a/tests/e2e/preview-api-e2e.sh b/tests/e2e/preview-api-e2e.sh new file mode 100755 index 0000000..1bfdf86 --- /dev/null +++ b/tests/e2e/preview-api-e2e.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# +# API-level end-to-end test of the editor-preview serving contract v2 against a +# REAL running Nextcloud (no browser). It proves the load-bearing security and +# protocol properties that unit tests cannot: management CSRF enforcement, +# owner-scoping, the opaque sandbox CSP on the authless serving route, the +# corrected bare-root and dropped-part behaviours, and cleanup. +# +# Auth is cookie/session based ON PURPOSE: only cookie-authenticated requests +# exercise Nextcloud's CSRF middleware, so asserting a management POST without a +# requesttoken is rejected is a genuine CSRF-enforcement proof (Basic Auth would +# bypass CSRF and hide the property). +# +# Required environment: +# EXE_E2E_BASE base URL incl. the front controller, e.g. +# http://127.0.0.1:8080/index.php +# EXE_E2E_USER1 / EXE_E2E_PASS1 owner +# EXE_E2E_USER2 / EXE_E2E_PASS2 a second, non-owner user +set -euo pipefail + +BASE="${EXE_E2E_BASE:?EXE_E2E_BASE is required}" +TMP="${RUNNER_TEMP:-/tmp}" +JAR1="$TMP/exe-e2e-cookies-1.txt" +JAR2="$TMP/exe-e2e-cookies-2.txt" +: > "$JAR1" +: > "$JAR2" + +fail() { echo "E2E FAIL: $*" >&2; exit 1; } + +# Scrape the current requesttoken from a rendered Nextcloud page (it is exposed +# as the head element's data-requesttoken — the same value the CSRF middleware +# validates). +scrape_token() { grep -o 'data-requesttoken="[^"]*"' | head -1 | sed 's/^[^"]*"//;s/"$//'; } + +# Log $user in through the real login form (GET for the token + SameSite +# cookies, POST the credentials) and echo a fresh post-login requesttoken. +login() { + local user="$1" pass="$2" jar="$3" page rt + page="$(curl -s -c "$jar" -b "$jar" "$BASE/login")" + rt="$(printf '%s' "$page" | scrape_token)" + [ -n "$rt" ] || fail "no pre-login requesttoken for $user" + curl -s -c "$jar" -b "$jar" -o /dev/null \ + --data-urlencode "user=$user" \ + --data-urlencode "password=$pass" \ + --data-urlencode "requesttoken=$rt" \ + "$BASE/login" + page="$(curl -s -c "$jar" -b "$jar" "$BASE/apps/files/")" + printf '%s' "$page" | scrape_token +} + +MGMT="$BASE/apps/exelearning/api/preview-session" +SERVE="$BASE/apps/exelearning/preview" + +RT1="$(login "$EXE_E2E_USER1" "$EXE_E2E_PASS1" "$JAR1")" +[ -n "$RT1" ] || fail "no post-login requesttoken for user1" + +# 1. CSRF enforced — a cookie-authenticated create WITHOUT a requesttoken → 412. +code="$(curl -s -o /dev/null -w '%{http_code}' -c "$JAR1" -b "$JAR1" -X POST "$MGMT")" +[ "$code" = "412" ] || fail "create without requesttoken expected 412, got $code" +echo " ok: management enforces CSRF (412 without requesttoken)" + +# 2. Create WITH requesttoken → 201 { previewId, protocolVersion: 2 }. +resp="$(curl -s -c "$JAR1" -b "$JAR1" -H "requesttoken: $RT1" -X POST "$MGMT")" +pid="$(printf '%s' "$resp" | jq -r '.previewId // empty')" +[ -n "$pid" ] || fail "create returned no previewId: $resp" +[ "$(printf '%s' "$resp" | jq -r '.protocolVersion')" = "2" ] || fail "protocolVersion != 2: $resp" +echo " ok: created session $pid" + +# 3. Upload an asset (multipart: assets JSON + index-aligned files[]). +KEY="aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8" +printf 'IMGBYTES!' > "$TMP/asset.bin" # 9 bytes +curl -s -c "$JAR1" -b "$JAR1" -H "requesttoken: $RT1" \ + -F "assets=[{\"key\":\"$KEY\",\"size\":9}]" \ + -F "files[]=@$TMP/asset.bin" \ + -X POST "$MGMT/$pid/assets" >/dev/null +echo " ok: uploaded asset" + +# 4. Publish revision 1. +printf 'preview-v1' > "$TMP/index.html" +resp="$(curl -s -c "$JAR1" -b "$JAR1" -H "requesttoken: $RT1" \ + -F "revision={\"baseRevision\":0,\"nextRevision\":1,\"writes\":[\"index.html\"],\"deletes\":[],\"assetRefs\":{\"content/photo.png\":\"$KEY\"},\"fixedRefs\":{}}" \ + -F "files[]=@$TMP/index.html" \ + -X POST "$MGMT/$pid/revisions")" +[ "$(printf '%s' "$resp" | jq -r '.revision')" = "1" ] || fail "publish revision != 1: $resp" +echo " ok: published revision 1" + +# 5. Serve (authless, NO cookie): 200 + opaque sandbox CSP + no allow-same-origin. +hdrs="$(curl -s -D - -o "$TMP/served.html" "$SERVE/$pid/index.html")" +printf '%s' "$hdrs" | grep -q '^HTTP/[0-9.]* 200' || fail "serve not 200: $(printf '%s' "$hdrs" | head -1)" +csp="$(printf '%s' "$hdrs" | grep -i '^content-security-policy:' || true)" +printf '%s' "$csp" | grep -qi 'sandbox' || fail "serving CSP missing sandbox directive: $csp" +if printf '%s' "$csp" | grep -qi 'allow-same-origin'; then + fail "serving CSP must NOT contain allow-same-origin: $csp" +fi +grep -q 'preview-v1' "$TMP/served.html" || fail "served body missing revision content" +echo " ok: authless serve is 200 with opaque sandbox CSP (no allow-same-origin)" + +# 6. Bare capability root → 302 (never inline index.html bytes). +code="$(curl -s -o /dev/null -w '%{http_code}' "$SERVE/$pid")" +[ "$code" = "302" ] || fail "bare root expected 302, got $code" +echo " ok: bare root 302" + +# 7. Owner-scoping — user2 DELETE on user1's session → 403. +RT2="$(login "$EXE_E2E_USER2" "$EXE_E2E_PASS2" "$JAR2")" +[ -n "$RT2" ] || fail "no post-login requesttoken for user2" +code="$(curl -s -o /dev/null -w '%{http_code}' -c "$JAR2" -b "$JAR2" -H "requesttoken: $RT2" -X DELETE "$MGMT/$pid")" +[ "$code" = "403" ] || fail "cross-user delete expected 403, got $code" +echo " ok: cross-user management is 403" + +# 8. Dropped multipart part — 1 write declared, 0 file parts → 400, revision unchanged. +code="$(curl -s -o /dev/null -w '%{http_code}' -c "$JAR1" -b "$JAR1" -H "requesttoken: $RT1" \ + -F "revision={\"baseRevision\":1,\"nextRevision\":2,\"writes\":[\"index.html\"],\"deletes\":[],\"assetRefs\":{},\"fixedRefs\":{}}" \ + -X POST "$MGMT/$pid/revisions")" +[ "$code" = "400" ] || fail "dropped-part revision expected 400, got $code" +curl -s "$SERVE/$pid/index.html" | grep -q 'preview-v1' || fail "revision advanced despite dropped part" +echo " ok: dropped-part revision rejected (400), revision pointer unchanged" + +# 9. Owner DELETE → 200, then serve → 404. +code="$(curl -s -o /dev/null -w '%{http_code}' -c "$JAR1" -b "$JAR1" -H "requesttoken: $RT1" -X DELETE "$MGMT/$pid")" +[ "$code" = "200" ] || fail "owner delete expected 200, got $code" +code="$(curl -s -o /dev/null -w '%{http_code}' "$SERVE/$pid/index.html")" +[ "$code" = "404" ] || fail "serve after delete expected 404, got $code" +echo " ok: owner delete 200, serve after delete 404" + +echo "Preview API E2E: all checks passed." From d7a2a67c787679939108c8936aff9529ee9f04ed Mon Sep 17 00:00:00 2001 From: erseco Date: Sat, 11 Jul 2026 12:49:15 +0100 Subject: [PATCH 27/43] ci(preview): probe /status.php for readiness in the preview E2E job The built-in PHP server's document root is the Nextcloud tree, so the top-level status.php is served directly; /index.php/status.php routes through the front controller and 404s. Probe /status.php (and assert installed:true) so the readiness gate reflects a genuinely ready server. --- .github/workflows/ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5231256..d8dbaaa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -240,14 +240,17 @@ jobs: 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/index.php/status.php" >/dev/null; then ready=1; break; fi + 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"; cat "${RUNNER_TEMP}/nc-server.log"; exit 1; } + [ "$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!' \ From f4549fd955652986aeaa3dcfdb9de5865a482e2a Mon Sep 17 00:00:00 2001 From: erseco Date: Sat, 11 Jul 2026 12:54:32 +0100 Subject: [PATCH 28/43] fix(preview): align Range classification with the canonical contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the frozen contract (v2.1 section 4 / RFC 9110 section 14.1.2), a range whose last-byte-pos is less than its first-byte-pos (bytes=5-2) is an INVALID spec and must be ignored (200 full body), not answered 416. parseRange now returns null for that case; 416 stays reserved for a valid but unsatisfiable single range — first-byte-pos at/after EOF (bytes=99-) or a zero-length suffix (bytes=-0). Adds bytes=5-2 (ignored) and bytes=-0 (416) tests. --- lib/Service/Preview/PreviewServer.php | 41 +++++++++++-------- .../Service/Preview/PreviewServerTest.php | 24 +++++++++-- 2 files changed, 44 insertions(+), 21 deletions(-) diff --git a/lib/Service/Preview/PreviewServer.php b/lib/Service/Preview/PreviewServer.php index d7ff308..62aff02 100644 --- a/lib/Service/Preview/PreviewServer.php +++ b/lib/Service/Preview/PreviewServer.php @@ -170,16 +170,16 @@ private function baseHeaders(): array { } /** - * Parse a single HTTP byte range against a known size, distinguishing the two - * outcomes the serving contract treats differently: + * Parse a single HTTP byte range against a known size, per the contract's + * canonical classification (RFC 9110 §14.1.2). Three outcomes: * - * - `null` — the header is malformed, a multi-range set, or a non-`bytes` - * unit. Per the contract it is IGNORED: the caller serves a normal 200 - * full body (never 416). This is the corrected behaviour — treating a - * header we don't understand as unsatisfiable was wrong. - * - `['satisfiable' => false]` — a syntactically valid single range that is - * unsatisfiable (first-byte-pos at/after EOF, an empty suffix, or an empty - * span). The caller emits 416. + * - `null` — IGNORE the header, serve a normal 200 full body. This covers a + * non-`bytes` unit, a multi-range set, any malformed value, AND a valid + * syntax whose last-byte-pos is less than its first-byte-pos (`bytes=5-2`) + * — an *invalid* spec, which RFC 9110 says to ignore, not 416. + * - `['satisfiable' => false]` — a valid single range that is *unsatisfiable*: + * first-byte-pos at/after EOF (`bytes=99-`) or a zero-length suffix + * (`bytes=-0`). The caller emits 416. * - `['satisfiable' => true, 'start' => int, 'end' => int]` — a satisfiable * single range (inclusive). The caller emits 206. * @@ -187,26 +187,31 @@ private function baseHeaders(): array { */ private function parseRange(string $range, int $size): ?array { if (preg_match('/^bytes=(\d*)-(\d*)$/', trim($range), $m) !== 1) { - return null; // malformed / multi-range / non-bytes unit → ignore (200) + return null; // non-bytes unit / multi-range / garbage → ignore (200) } [$rawStart, $rawEnd] = [$m[1], $m[2]]; if ($rawStart === '' && $rawEnd === '') { return null; // `bytes=-` carries no range → ignore (200) } if ($rawStart === '') { + // Suffix range `bytes=-N`: the last N bytes. A zero-length suffix or + // an empty entity is unsatisfiable (416); otherwise it clamps in. $suffix = (int)$rawEnd; - if ($suffix <= 0 || $size === 0) { + if ($suffix === 0 || $size === 0) { return ['satisfiable' => false]; } - $start = max(0, $size - $suffix); - $end = $size - 1; - } else { - $start = (int)$rawStart; - $end = $rawEnd === '' ? $size - 1 : min((int)$rawEnd, $size - 1); + return ['satisfiable' => true, 'start' => max(0, $size - $suffix), 'end' => $size - 1]; } - if ($start < 0 || $start >= $size || $start > $end) { - return ['satisfiable' => false]; + $start = (int)$rawStart; + if ($rawEnd !== '' && (int)$rawEnd < $start) { + // last-byte-pos < first-byte-pos is an INVALID spec, not an + // unsatisfiable one → ignore the header and serve 200. + return null; } + if ($start >= $size) { + return ['satisfiable' => false]; // first-byte-pos at/after EOF → 416 + } + $end = $rawEnd === '' ? $size - 1 : min((int)$rawEnd, $size - 1); return ['satisfiable' => true, 'start' => $start, 'end' => $end]; } diff --git a/tests/Unit/Service/Preview/PreviewServerTest.php b/tests/Unit/Service/Preview/PreviewServerTest.php index 9ab356e..4b7cefd 100644 --- a/tests/Unit/Service/Preview/PreviewServerTest.php +++ b/tests/Unit/Service/Preview/PreviewServerTest.php @@ -126,12 +126,27 @@ public function testSingleRangeReturns206(): void { self::assertSame('3', $response->headers['Content-Length']); } - public function testUnsatisfiableRangeReturns416(): void { - $response = $this->server()->serve($this->previewId, 'media/clip.mp4', null, 'bytes=99-'); - self::assertSame(416, $response->status); + /** + * 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. (The previous behaviour of 416 @@ -157,6 +172,9 @@ public static function ignoredRangeProvider(): array { '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'], ]; } From 40fff92079febf8cf6911f09d6f65d39fc17e4cf Mon Sep 17 00:00:00 2001 From: erseco Date: Sat, 11 Jul 2026 12:54:41 +0100 Subject: [PATCH 29/43] docs(preview): document audited CSRF-token durability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the CSRF-TTL audit conclusion: a Nextcloud request token is bound to a per-session secret, so the value injected into previewHttp.managementHeaders stays valid for the whole editing session (every getEncryptedValue() encoding validates against the same secret). The secret regenerates only on session-id regeneration (login/logout/re-auth), which reloads the parent page and re-injects a fresh token; the parent's keepalive heartbeat keeps the session alive meanwhile. The injected-token approach is durable — no postMessage refresh path is required now. --- docs/preview-serving-contract.md | 25 +++++++++++++++++-------- lib/Controller/EditorController.php | 19 ++++++++++++++----- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/docs/preview-serving-contract.md b/docs/preview-serving-contract.md index 4da9622..b0b61f7 100644 --- a/docs/preview-serving-contract.md +++ b/docs/preview-serving-contract.md @@ -231,14 +231,23 @@ The embedded editor is handed a `previewHttp` block in capability-root URL for a placeholder id and stripping the id segment. - `managementHeaders.requesttoken` is the **current Nextcloud CSRF token** — the same encrypted value the standard template layer exposes as `data-requesttoken` - (obtained from `CsrfTokenManager::getToken()->getEncryptedValue()`). It is - required because the management routes keep CSRF **on** (they are **not** - `#[NoCSRFRequired]`); the editor replays it on every management request. - Nextcloud rotates the token per session — the injected value is valid for the - editor session while the Nextcloud session is alive, and can go stale only - across a very long idle (session expiry). A refresh path (the parent re-issuing - a CONFIGURE postMessage with a fresh token) is a future enhancement, not built - here. + and `OC.requestToken` (obtained from + `CsrfTokenManager::getToken()->getEncryptedValue()`). It is required because the + management routes keep CSRF **on** (they are **not** `#[NoCSRFRequired]`); the + editor replays it on every management request. + +**CSRF-token durability (audited).** 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; whenever that happens the parent +Nextcloud page hosting the iframe is itself invalidated and reloads, re-running +the bootstrap and injecting a fresh token (and the parent's keepalive heartbeat +keeps the session and `OC.requestToken` alive meanwhile). The injected-token +approach is therefore **durable**; a postMessage CONFIGURE refresh into the +iframe is a future nicety, not a correctness requirement. - `previewTransport` / `previewBasePath` are **dead vocabulary** — the earlier single-`previewBasePath` idea was never implemented and the two-URL `previewHttp` model replaces it. diff --git a/lib/Controller/EditorController.php b/lib/Controller/EditorController.php index 3563c6b..3a1b8b4 100644 --- a/lib/Controller/EditorController.php +++ b/lib/Controller/EditorController.php @@ -315,11 +315,20 @@ public function iframe(): DataDisplayResponse|DataResponse { * CSRF ON (they are NOT `#[NoCSRFRequired]`); the editor replays it on every * management request. * - * Token lifetime: Nextcloud rotates the CSRF token per session. The injected - * value stays valid for the whole editor session while the Nextcloud session - * is alive, but can go stale across a very long idle (session expiry). A future - * refresh path — the parent page re-issuing a CONFIGURE postMessage with a - * fresh token — is intentionally out of scope here. + * 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{protocolVersion:int,managementBaseUrl:string,servingBaseUrl:string,managementHeaders:object} */ From 1195851d770f1f2af632e5a53024a1b6f1a3b856 Mon Sep 17 00:00:00 2001 From: erseco Date: Sat, 11 Jul 2026 13:00:36 +0100 Subject: [PATCH 30/43] ci(preview): harden preview E2E auth against set -e and add diagnostics The login helper died silently before the first assertion: under set -euo pipefail, scrape_token's grep returns non-zero on no match, which tripped set -e inside the RT1=$(login ...) command substitution and aborted with no output. Guard scrape_token with '|| true' so an empty result is handled by the caller's check instead of killing the script, capture the login pages to files and dump a diagnostic (status + snippet) when the requesttoken is absent, and follow post-login redirects with -L. Also tail data/nextcloud.log on failure (occ has no log:tail command). --- .github/workflows/ci.yml | 2 +- tests/e2e/preview-api-e2e.sh | 24 ++++++++++++++++-------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8dbaaa..da3ad58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -256,6 +256,6 @@ jobs: 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 ---'; php occ log:tail --num 60 || true; exit 1; } + || { 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/tests/e2e/preview-api-e2e.sh b/tests/e2e/preview-api-e2e.sh index 1bfdf86..b44b091 100755 --- a/tests/e2e/preview-api-e2e.sh +++ b/tests/e2e/preview-api-e2e.sh @@ -29,23 +29,31 @@ fail() { echo "E2E FAIL: $*" >&2; exit 1; } # Scrape the current requesttoken from a rendered Nextcloud page (it is exposed # as the head element's data-requesttoken — the same value the CSRF middleware -# validates). -scrape_token() { grep -o 'data-requesttoken="[^"]*"' | head -1 | sed 's/^[^"]*"//;s/"$//'; } +# validates). The trailing `|| true` keeps a no-match from tripping `set -e` +# inside a command substitution (the caller checks for an empty result). +scrape_token() { grep -o 'data-requesttoken="[^"]*"' | head -1 | sed 's/^[^"]*"//;s/"$//' || true; } # Log $user in through the real login form (GET for the token + SameSite # cookies, POST the credentials) and echo a fresh post-login requesttoken. +# -L follows Nextcloud's post-login redirect; pages are captured to files so a +# missing token can be diagnosed instead of failing opaquely. login() { local user="$1" pass="$2" jar="$3" page rt - page="$(curl -s -c "$jar" -b "$jar" "$BASE/login")" - rt="$(printf '%s' "$page" | scrape_token)" - [ -n "$rt" ] || fail "no pre-login requesttoken for $user" - curl -s -c "$jar" -b "$jar" -o /dev/null \ + page="$TMP/exe-e2e-page-$user.html" + curl -sL -c "$jar" -b "$jar" "$BASE/login" -o "$page" + rt="$(scrape_token < "$page")" + if [ -z "$rt" ]; then + echo "diagnostic: login page for $user had no data-requesttoken (first 400 bytes):" >&2 + head -c 400 "$page" >&2; echo >&2 + fail "no pre-login requesttoken for $user" + fi + curl -sL -c "$jar" -b "$jar" -o /dev/null \ --data-urlencode "user=$user" \ --data-urlencode "password=$pass" \ --data-urlencode "requesttoken=$rt" \ "$BASE/login" - page="$(curl -s -c "$jar" -b "$jar" "$BASE/apps/files/")" - printf '%s' "$page" | scrape_token + curl -sL -c "$jar" -b "$jar" "$BASE/apps/files/" -o "$page" + scrape_token < "$page" } MGMT="$BASE/apps/exelearning/api/preview-session" From 472ffe6717d4196f18febe66dd43fcf9e511ae85 Mon Sep 17 00:00:00 2001 From: erseco Date: Sat, 11 Jul 2026 13:25:24 +0100 Subject: [PATCH 31/43] ci(preview): get post-login requesttoken from /csrftoken, not app HTML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-login step scraped /apps/files/ HTML for data-requesttoken, which came back empty under php -S (the failure the last run surfaced). Replace it with Nextcloud's canonical /csrftoken JSON endpoint — the same source OC.requestToken refreshes from — parsed with jq. It requires a live authenticated session, so it doubles as a login check: an unauthenticated hit returns a login redirect (not JSON) and yields an empty token with a diagnostic dump, instead of masking a login failure with a guest token. --- tests/e2e/preview-api-e2e.sh | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/tests/e2e/preview-api-e2e.sh b/tests/e2e/preview-api-e2e.sh index b44b091..19f12de 100755 --- a/tests/e2e/preview-api-e2e.sh +++ b/tests/e2e/preview-api-e2e.sh @@ -33,10 +33,25 @@ fail() { echo "E2E FAIL: $*" >&2; exit 1; } # inside a command substitution (the caller checks for an empty result). scrape_token() { grep -o 'data-requesttoken="[^"]*"' | head -1 | sed 's/^[^"]*"//;s/"$//' || true; } -# Log $user in through the real login form (GET for the token + SameSite +# Fetch a fresh requesttoken for an authenticated session from the canonical +# /csrftoken JSON endpoint (what OC.requestToken refreshes from — no HTML +# scraping, and it requires a live session so it doubles as a login check). +# Echoes the token, or dumps a diagnostic and returns non-zero (login failed) so +# the caller reports it instead of dying opaquely. A guest/unauthenticated hit +# returns a login-page redirect body rather than JSON, so jq yields empty. +post_login_token() { + local jar="$1" resp rt + resp="$(curl -sL -c "$jar" -b "$jar" "$BASE/csrftoken")" + rt="$(printf '%s' "$resp" | jq -r '.token // empty' 2>/dev/null || true)" + if [ -n "$rt" ]; then printf '%s' "$rt"; return 0; fi + echo "diagnostic: /csrftoken returned no token (login likely failed). First 200 bytes:" >&2 + printf '%s' "$resp" | head -c 200 >&2; echo >&2 + return 1 +} + +# Log $user in through the real login form (GET for the login token + SameSite # cookies, POST the credentials) and echo a fresh post-login requesttoken. -# -L follows Nextcloud's post-login redirect; pages are captured to files so a -# missing token can be diagnosed instead of failing opaquely. +# -L follows Nextcloud's post-login redirect and keeps cookie-jar continuity. login() { local user="$1" pass="$2" jar="$3" page rt page="$TMP/exe-e2e-page-$user.html" @@ -52,8 +67,7 @@ login() { --data-urlencode "password=$pass" \ --data-urlencode "requesttoken=$rt" \ "$BASE/login" - curl -sL -c "$jar" -b "$jar" "$BASE/apps/files/" -o "$page" - scrape_token < "$page" + post_login_token "$jar" || fail "no post-login requesttoken for $user (login did not establish a session)" } MGMT="$BASE/apps/exelearning/api/preview-session" From 3041e3065e67c357fb43cdc4d9c5603635dbe36c Mon Sep 17 00:00:00 2001 From: erseco Date: Sat, 11 Jul 2026 13:50:16 +0100 Subject: [PATCH 32/43] ci(preview): force http protocol + verify session auth in preview E2E MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The management create returned 401 (unauthenticated), not 412: the cookie session was never authenticated. Root cause is almost certainly Secure session cookies — behind CI's proxy Nextcloud defaults to https, marks the session cookie Secure, and curl refuses to send it over the plain-http php -S server, so every authenticated request falls through to 401 (while /csrftoken still hands a guest token back, masking it). Force overwriteprotocol=http so the session cookie is not Secure. Also make login() verify authentication explicitly via the OCS cloud/user endpoint (which returns the authenticated user id, unlike /csrftoken) and dump the login POST status + cookie-jar names on failure, and dump the create body (asserting it mentions CSRF) when the 412 assertion misses — so any remaining auth issue is diagnosable in one run instead of opaque. --- .github/workflows/ci.yml | 4 ++++ tests/e2e/preview-api-e2e.sh | 29 ++++++++++++++++++++++++----- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da3ad58..06dac55 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -234,6 +234,10 @@ jobs: php occ app:enable exelearning php occ config:system:set trusted_domains 1 --value=127.0.0.1 php occ config:system:set overwrite.cli.url --value="http://127.0.0.1:8080/index.php" + # Serve over plain HTTP: force the protocol to http so Nextcloud does + # NOT mark session cookies Secure (curl would refuse to send a Secure + # cookie over http, leaving management requests unauthenticated → 401). + php occ config:system:set overwriteprotocol --value=http export OC_PASS='Preview-e2e-owner-1!' php occ user:add --password-from-env previewe2e1 diff --git a/tests/e2e/preview-api-e2e.sh b/tests/e2e/preview-api-e2e.sh index 19f12de..5c2aaad 100755 --- a/tests/e2e/preview-api-e2e.sh +++ b/tests/e2e/preview-api-e2e.sh @@ -19,6 +19,9 @@ set -euo pipefail BASE="${EXE_E2E_BASE:?EXE_E2E_BASE is required}" +# The host root (no front controller) for endpoints served as top-level scripts +# under php -S: status.php and the OCS entry points (/ocs/v2.php/...). +ROOT="${BASE%/index.php}" TMP="${RUNNER_TEMP:-/tmp}" JAR1="$TMP/exe-e2e-cookies-1.txt" JAR2="$TMP/exe-e2e-cookies-2.txt" @@ -62,11 +65,22 @@ login() { head -c 400 "$page" >&2; echo >&2 fail "no pre-login requesttoken for $user" fi - curl -sL -c "$jar" -b "$jar" -o /dev/null \ + local code + code="$(curl -sL -c "$jar" -b "$jar" -o /dev/null -w '%{http_code}' \ --data-urlencode "user=$user" \ --data-urlencode "password=$pass" \ --data-urlencode "requesttoken=$rt" \ - "$BASE/login" + "$BASE/login")" + # Verify the session is genuinely authenticated. /csrftoken hands out a + # token even to a guest session, so it cannot confirm login; the OCS + # cloud/user endpoint returns the authenticated user id or fails. + local who + who="$(curl -s -H 'OCS-APIRequest: true' -b "$jar" "$ROOT/ocs/v2.php/cloud/user?format=json" | jq -r '.ocs.data.id // empty' 2>/dev/null || true)" + if [ "$who" != "$user" ]; then + echo "diagnostic: login POST for $user returned HTTP $code but the session is NOT authenticated (cloud/user id='$who')." >&2 + echo "diagnostic: cookie names in jar: $(grep -v '^#' "$jar" 2>/dev/null | awk '{print $6}' | tr '\n' ' ')" >&2 + fail "login did not authenticate $user (session cookie likely rejected — check Secure flag / protocol)" + fi post_login_token "$jar" || fail "no post-login requesttoken for $user (login did not establish a session)" } @@ -76,9 +90,14 @@ SERVE="$BASE/apps/exelearning/preview" RT1="$(login "$EXE_E2E_USER1" "$EXE_E2E_PASS1" "$JAR1")" [ -n "$RT1" ] || fail "no post-login requesttoken for user1" -# 1. CSRF enforced — a cookie-authenticated create WITHOUT a requesttoken → 412. -code="$(curl -s -o /dev/null -w '%{http_code}' -c "$JAR1" -b "$JAR1" -X POST "$MGMT")" -[ "$code" = "412" ] || fail "create without requesttoken expected 412, got $code" +# 1. CSRF enforced — an AUTHENTICATED create WITHOUT a requesttoken → 412. The +# session cookie jar is sent (so the request passes auth and reaches the CSRF +# middleware); only the requesttoken header is omitted. +code="$(curl -s -o "$TMP/step1.txt" -w '%{http_code}' -c "$JAR1" -b "$JAR1" -X POST "$MGMT")" +if [ "$code" != "412" ]; then + fail "create without requesttoken expected 412, got $code (body: $(head -c 200 "$TMP/step1.txt"))" +fi +grep -qi 'csrf' "$TMP/step1.txt" || echo " note: 412 body did not explicitly mention CSRF: $(head -c 120 "$TMP/step1.txt")" echo " ok: management enforces CSRF (412 without requesttoken)" # 2. Create WITH requesttoken → 201 { previewId, protocolVersion: 2 }. From 26eac92ca60cb26eb58ec29568a1464980e02889 Mon Sep 17 00:00:00 2001 From: erseco Date: Sat, 11 Jul 2026 14:26:23 +0100 Subject: [PATCH 33/43] ci(preview): complete plain-http config + raw login diagnostics The login POST returns 200 (not the 303 of a successful login) and the session stays unauthenticated: the login form's own strict-cookie/CSRF check fails because the SameSite/session cookies aren't round-tripping over php -S plain HTTP. Complete the plain-HTTP config: overwrite.cli.url without the /index.php suffix (so cookies get Path=/), overwriteprotocol=http, and delete forcessl/overwritehost so nothing forces https (Secure cookies). Make the failure fully diagnosable in one run: dump the login flow's Set-Cookie headers, the raw cookie jar, and the login POST body (which carries NC's error) so the exact cookie attribute or login error is visible instead of inferred. --- .github/workflows/ci.yml | 13 +++++++++---- tests/e2e/preview-api-e2e.sh | 13 ++++++++++--- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06dac55..65c426f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -233,11 +233,16 @@ jobs: set -euo pipefail php occ app:enable exelearning php occ config:system:set trusted_domains 1 --value=127.0.0.1 - php occ config:system:set overwrite.cli.url --value="http://127.0.0.1:8080/index.php" - # Serve over plain HTTP: force the protocol to http so Nextcloud does - # NOT mark session cookies Secure (curl would refuse to send a Secure - # cookie over http, leaving management requests unauthenticated → 401). + # 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 diff --git a/tests/e2e/preview-api-e2e.sh b/tests/e2e/preview-api-e2e.sh index 5c2aaad..7eb1108 100755 --- a/tests/e2e/preview-api-e2e.sh +++ b/tests/e2e/preview-api-e2e.sh @@ -65,8 +65,10 @@ login() { head -c 400 "$page" >&2; echo >&2 fail "no pre-login requesttoken for $user" fi - local code - code="$(curl -sL -c "$jar" -b "$jar" -o /dev/null -w '%{http_code}' \ + local code hdrs body + hdrs="$TMP/exe-e2e-login-hdrs-$user.txt" + body="$TMP/exe-e2e-login-body-$user.html" + code="$(curl -sL -c "$jar" -b "$jar" -D "$hdrs" -o "$body" -w '%{http_code}' \ --data-urlencode "user=$user" \ --data-urlencode "password=$pass" \ --data-urlencode "requesttoken=$rt" \ @@ -78,7 +80,12 @@ login() { who="$(curl -s -H 'OCS-APIRequest: true' -b "$jar" "$ROOT/ocs/v2.php/cloud/user?format=json" | jq -r '.ocs.data.id // empty' 2>/dev/null || true)" if [ "$who" != "$user" ]; then echo "diagnostic: login POST for $user returned HTTP $code but the session is NOT authenticated (cloud/user id='$who')." >&2 - echo "diagnostic: cookie names in jar: $(grep -v '^#' "$jar" 2>/dev/null | awk '{print $6}' | tr '\n' ' ')" >&2 + echo "diagnostic: Set-Cookie headers from the login flow:" >&2 + grep -i '^set-cookie:' "$hdrs" 2>/dev/null >&2 || echo " (none)" >&2 + echo "diagnostic: raw cookie jar ($jar):" >&2 + cat "$jar" 2>/dev/null >&2 || true + echo "diagnostic: login POST body (first 400 bytes, may carry the NC error):" >&2 + head -c 400 "$body" >&2; echo >&2 fail "login did not authenticate $user (session cookie likely rejected — check Secure flag / protocol)" fi post_login_token "$jar" || fail "no post-login requesttoken for $user (login did not establish a session)" From 56eb4fa3518b022e187f16227999720c66245484 Mon Sep 17 00:00:00 2001 From: erseco Date: Sat, 11 Jul 2026 14:46:14 +0100 Subject: [PATCH 34/43] ci(preview): drive the preview E2E over cookieless Basic auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser login form's SameSite/session cookies do not round-trip under the built-in php -S server (the raw cookie jar stayed empty and the login POST re-rendered the form), so a cookie session can't be established there. Switch the E2E to HTTP Basic auth, which authenticates per request via the Authorization header with no cookies. CSRF is still exercised honestly using Nextcloud's own rule (Request::passesCSRFCheck): the positive calls send the OCS-APIRequest header (which makes passesCSRFCheck pass without a session/token), and an AUTHENTICATED management POST WITHOUT that header is rejected 412 'CSRF check failed' — the real proof that the management routes are not #[NoCSRFRequired]. The full round-trip, authless opaque serving (200 + sandbox CSP, no allow-same-origin), bare-root 302, ownership 403, dropped-part 400 (revision unchanged) and post-delete 404 all run cookielessly against real Nextcloud. No product code changes; harness only. --- docs/preview-serving-contract.md | 25 +++-- tests/e2e/preview-api-e2e.sh | 167 +++++++++++-------------------- 2 files changed, 79 insertions(+), 113 deletions(-) diff --git a/docs/preview-serving-contract.md b/docs/preview-serving-contract.md index b0b61f7..5d3c365 100644 --- a/docs/preview-serving-contract.md +++ b/docs/preview-serving-contract.md @@ -274,10 +274,21 @@ contract CHANGES above (bare-root `302` and the malformed-Range→`200` rule). I and this app re-syncs then. The new behaviours are covered directly by `PreviewServerTest` in the meantime. -The API-level end-to-end job in [`ci.yml`](../.github/workflows/ci.yml) exercises -the real management + serving surface against a live Nextcloud: it proves CSRF is -enforced on management (401/412 without a `requesttoken`), cross-user ownership is -denied (403), an asset upload + revision publish round-trips, the serving response -carries the `sandbox` CSP with no `allow-same-origin`, a dropped multipart part is -rejected (400) without advancing the revision, and a deleted session stops serving -(404). +The API-level end-to-end job in [`ci.yml`](../.github/workflows/ci.yml) +(`tests/e2e/preview-api-e2e.sh`) exercises the real management + serving surface +against a live Nextcloud served over the built-in php server. It authenticates +with **HTTP Basic auth** (per-request, cookieless — the browser login form's +SameSite/session cookies do not round-trip under `php -S`, so a cookie login is +unreliable there) and still exercises CSRF honestly using Nextcloud's own rule: +`Request::passesCSRFCheck()` passes when the `OCS-APIRequest` header is present, +so the positive calls send it, and an **authenticated management POST *without* +that header (and no requesttoken) is rejected 412 "CSRF check failed"** — the real +proof that the management routes are not `#[NoCSRFRequired]`. It then asserts the +create → asset → revision → serve round-trip, the authless serving response +(`200` + the `sandbox` CSP with no `allow-same-origin`), the bare-root `302`, +cross-user ownership denial (`403`), a dropped multipart part rejected `400` +without advancing the revision, and a deleted session serving `404`. + +The full **browser** editor-iframe E2E (opening the editor, external video, the +interactive-video iDevice) stays blocked on a capable editor build and is +documented as that dependency rather than faked. diff --git a/tests/e2e/preview-api-e2e.sh b/tests/e2e/preview-api-e2e.sh index 7eb1108..2c6496a 100755 --- a/tests/e2e/preview-api-e2e.sh +++ b/tests/e2e/preview-api-e2e.sh @@ -1,15 +1,28 @@ #!/usr/bin/env bash # # API-level end-to-end test of the editor-preview serving contract v2 against a -# REAL running Nextcloud (no browser). It proves the load-bearing security and -# protocol properties that unit tests cannot: management CSRF enforcement, -# owner-scoping, the opaque sandbox CSP on the authless serving route, the -# corrected bare-root and dropped-part behaviours, and cleanup. +# REAL running Nextcloud (no browser). # -# Auth is cookie/session based ON PURPOSE: only cookie-authenticated requests -# exercise Nextcloud's CSRF middleware, so asserting a management POST without a -# requesttoken is rejected is a genuine CSRF-enforcement proof (Basic Auth would -# bypass CSRF and hide the property). +# Authentication is HTTP Basic auth: it authenticates per request via the +# Authorization header and needs NO cookies, so it is reliable under the built-in +# php -S server (where the browser login form's SameSite/session cookies do not +# round-trip). CSRF is still exercised honestly, using Nextcloud's own rules +# (lib/private/AppFramework/Http/Request.php::passesCSRFCheck): +# +# * passesCSRFCheck() returns true when the OCS-APIRequest header is present, +# so the positive management calls send it (cookieless, no requesttoken). +# * Omitting that header on an AUTHENTICATED management POST leaves the CSRF +# check to fail (no token, no OCS-APIRequest) → the middleware answers 412 +# "CSRF check failed" — proving the route is NOT #[NoCSRFRequired]. That is +# the real CSRF-enforcement assertion. +# +# It asserts: CSRF enforcement (412), the create->asset->revision->serve +# round-trip, the authless opaque serving response (200 + sandbox CSP, no +# allow-same-origin), the bare-root 302, owner-scoping (403), dropped-part +# rejection (400) without advancing the revision, and 404 after delete. +# +# Full editor-iframe browser E2E (external video / interactive-video) stays +# blocked on a capable editor build and is documented as such, not faked here. # # Required environment: # EXE_E2E_BASE base URL incl. the front controller, e.g. @@ -20,95 +33,42 @@ set -euo pipefail BASE="${EXE_E2E_BASE:?EXE_E2E_BASE is required}" # The host root (no front controller) for endpoints served as top-level scripts -# under php -S: status.php and the OCS entry points (/ocs/v2.php/...). +# under php -S: the OCS entry point (/ocs/v2.php/...). ROOT="${BASE%/index.php}" TMP="${RUNNER_TEMP:-/tmp}" -JAR1="$TMP/exe-e2e-cookies-1.txt" -JAR2="$TMP/exe-e2e-cookies-2.txt" -: > "$JAR1" -: > "$JAR2" - -fail() { echo "E2E FAIL: $*" >&2; exit 1; } - -# Scrape the current requesttoken from a rendered Nextcloud page (it is exposed -# as the head element's data-requesttoken — the same value the CSRF middleware -# validates). The trailing `|| true` keeps a no-match from tripping `set -e` -# inside a command substitution (the caller checks for an empty result). -scrape_token() { grep -o 'data-requesttoken="[^"]*"' | head -1 | sed 's/^[^"]*"//;s/"$//' || true; } - -# Fetch a fresh requesttoken for an authenticated session from the canonical -# /csrftoken JSON endpoint (what OC.requestToken refreshes from — no HTML -# scraping, and it requires a live session so it doubles as a login check). -# Echoes the token, or dumps a diagnostic and returns non-zero (login failed) so -# the caller reports it instead of dying opaquely. A guest/unauthenticated hit -# returns a login-page redirect body rather than JSON, so jq yields empty. -post_login_token() { - local jar="$1" resp rt - resp="$(curl -sL -c "$jar" -b "$jar" "$BASE/csrftoken")" - rt="$(printf '%s' "$resp" | jq -r '.token // empty' 2>/dev/null || true)" - if [ -n "$rt" ]; then printf '%s' "$rt"; return 0; fi - echo "diagnostic: /csrftoken returned no token (login likely failed). First 200 bytes:" >&2 - printf '%s' "$resp" | head -c 200 >&2; echo >&2 - return 1 -} - -# Log $user in through the real login form (GET for the login token + SameSite -# cookies, POST the credentials) and echo a fresh post-login requesttoken. -# -L follows Nextcloud's post-login redirect and keeps cookie-jar continuity. -login() { - local user="$1" pass="$2" jar="$3" page rt - page="$TMP/exe-e2e-page-$user.html" - curl -sL -c "$jar" -b "$jar" "$BASE/login" -o "$page" - rt="$(scrape_token < "$page")" - if [ -z "$rt" ]; then - echo "diagnostic: login page for $user had no data-requesttoken (first 400 bytes):" >&2 - head -c 400 "$page" >&2; echo >&2 - fail "no pre-login requesttoken for $user" - fi - local code hdrs body - hdrs="$TMP/exe-e2e-login-hdrs-$user.txt" - body="$TMP/exe-e2e-login-body-$user.html" - code="$(curl -sL -c "$jar" -b "$jar" -D "$hdrs" -o "$body" -w '%{http_code}' \ - --data-urlencode "user=$user" \ - --data-urlencode "password=$pass" \ - --data-urlencode "requesttoken=$rt" \ - "$BASE/login")" - # Verify the session is genuinely authenticated. /csrftoken hands out a - # token even to a guest session, so it cannot confirm login; the OCS - # cloud/user endpoint returns the authenticated user id or fails. - local who - who="$(curl -s -H 'OCS-APIRequest: true' -b "$jar" "$ROOT/ocs/v2.php/cloud/user?format=json" | jq -r '.ocs.data.id // empty' 2>/dev/null || true)" - if [ "$who" != "$user" ]; then - echo "diagnostic: login POST for $user returned HTTP $code but the session is NOT authenticated (cloud/user id='$who')." >&2 - echo "diagnostic: Set-Cookie headers from the login flow:" >&2 - grep -i '^set-cookie:' "$hdrs" 2>/dev/null >&2 || echo " (none)" >&2 - echo "diagnostic: raw cookie jar ($jar):" >&2 - cat "$jar" 2>/dev/null >&2 || true - echo "diagnostic: login POST body (first 400 bytes, may carry the NC error):" >&2 - head -c 400 "$body" >&2; echo >&2 - fail "login did not authenticate $user (session cookie likely rejected — check Secure flag / protocol)" - fi - post_login_token "$jar" || fail "no post-login requesttoken for $user (login did not establish a session)" -} - +U1="${EXE_E2E_USER1:?EXE_E2E_USER1 is required}" +P1="${EXE_E2E_PASS1:?EXE_E2E_PASS1 is required}" +U2="${EXE_E2E_USER2:?EXE_E2E_USER2 is required}" +P2="${EXE_E2E_PASS2:?EXE_E2E_PASS2 is required}" MGMT="$BASE/apps/exelearning/api/preview-session" SERVE="$BASE/apps/exelearning/preview" -RT1="$(login "$EXE_E2E_USER1" "$EXE_E2E_PASS1" "$JAR1")" -[ -n "$RT1" ] || fail "no post-login requesttoken for user1" - -# 1. CSRF enforced — an AUTHENTICATED create WITHOUT a requesttoken → 412. The -# session cookie jar is sent (so the request passes auth and reaches the CSRF -# middleware); only the requesttoken header is omitted. -code="$(curl -s -o "$TMP/step1.txt" -w '%{http_code}' -c "$JAR1" -b "$JAR1" -X POST "$MGMT")" -if [ "$code" != "412" ]; then - fail "create without requesttoken expected 412, got $code (body: $(head -c 200 "$TMP/step1.txt"))" -fi -grep -qi 'csrf' "$TMP/step1.txt" || echo " note: 412 body did not explicitly mention CSRF: $(head -c 120 "$TMP/step1.txt")" -echo " ok: management enforces CSRF (412 without requesttoken)" +fail() { echo "E2E FAIL: $*" >&2; exit 1; } -# 2. Create WITH requesttoken → 201 { previewId, protocolVersion: 2 }. -resp="$(curl -s -c "$JAR1" -b "$JAR1" -H "requesttoken: $RT1" -X POST "$MGMT")" +# Authenticated management request. The OCS-APIRequest header makes Nextcloud's +# passesCSRFCheck() pass without a session cookie or requesttoken (the reliable, +# cookieless way to drive an authenticated AppFramework route in CI). It does not +# change the response shape — these are plain (non-OCS) DataResponses. +api() { local u="$1" p="$2" m="$3" url="$4"; shift 4; curl -s -u "$u:$p" -H 'OCS-APIRequest: true' -X "$m" "$@" "$url"; } +api_code() { local u="$1" p="$2" m="$3" url="$4"; shift 4; curl -s -o /dev/null -w '%{http_code}' -u "$u:$p" -H 'OCS-APIRequest: true' -X "$m" "$@" "$url"; } + +# 0. Sanity — Basic auth actually authenticates user1 (so a later 412 is a real +# CSRF rejection, not an auth failure hiding behind it). +who="$(curl -s -u "$U1:$P1" -H 'OCS-APIRequest: true' "$ROOT/ocs/v2.php/cloud/user?format=json" | jq -r '.ocs.data.id // empty' 2>/dev/null || true)" +[ "$who" = "$U1" ] || fail "Basic auth did not authenticate $U1 (cloud/user id='$who')" +echo " ok: Basic auth authenticates $U1" + +# 1. CSRF enforced — an AUTHENTICATED management POST WITHOUT the OCS-APIRequest +# header (and no requesttoken) trips the CSRF middleware → 412. Proves the +# management route is not #[NoCSRFRequired]. The 412 happens in middleware, so +# no session is created. +csrf="$(curl -s -o "$TMP/csrf.txt" -w '%{http_code}' -u "$U1:$P1" -X POST "$MGMT")" +[ "$csrf" = "412" ] || fail "CSRF proof: expected 412 without the CSRF-safe header, got $csrf (body: $(head -c 200 "$TMP/csrf.txt"))" +grep -qi 'csrf' "$TMP/csrf.txt" || echo " note: 412 body did not explicitly mention CSRF: $(head -c 120 "$TMP/csrf.txt")" +echo " ok: management enforces CSRF (412 for an authenticated request without the CSRF-safe header/token)" + +# 2. Create → 201 { previewId, protocolVersion: 2 }. +resp="$(api "$U1" "$P1" POST "$MGMT")" pid="$(printf '%s' "$resp" | jq -r '.previewId // empty')" [ -n "$pid" ] || fail "create returned no previewId: $resp" [ "$(printf '%s' "$resp" | jq -r '.protocolVersion')" = "2" ] || fail "protocolVersion != 2: $resp" @@ -117,22 +77,20 @@ echo " ok: created session $pid" # 3. Upload an asset (multipart: assets JSON + index-aligned files[]). KEY="aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8" printf 'IMGBYTES!' > "$TMP/asset.bin" # 9 bytes -curl -s -c "$JAR1" -b "$JAR1" -H "requesttoken: $RT1" \ +api "$U1" "$P1" POST "$MGMT/$pid/assets" \ -F "assets=[{\"key\":\"$KEY\",\"size\":9}]" \ - -F "files[]=@$TMP/asset.bin" \ - -X POST "$MGMT/$pid/assets" >/dev/null + -F "files[]=@$TMP/asset.bin" >/dev/null echo " ok: uploaded asset" # 4. Publish revision 1. printf 'preview-v1' > "$TMP/index.html" -resp="$(curl -s -c "$JAR1" -b "$JAR1" -H "requesttoken: $RT1" \ +resp="$(api "$U1" "$P1" POST "$MGMT/$pid/revisions" \ -F "revision={\"baseRevision\":0,\"nextRevision\":1,\"writes\":[\"index.html\"],\"deletes\":[],\"assetRefs\":{\"content/photo.png\":\"$KEY\"},\"fixedRefs\":{}}" \ - -F "files[]=@$TMP/index.html" \ - -X POST "$MGMT/$pid/revisions")" + -F "files[]=@$TMP/index.html")" [ "$(printf '%s' "$resp" | jq -r '.revision')" = "1" ] || fail "publish revision != 1: $resp" echo " ok: published revision 1" -# 5. Serve (authless, NO cookie): 200 + opaque sandbox CSP + no allow-same-origin. +# 5. Serve (authless, NO auth): 200 + opaque sandbox CSP + no allow-same-origin. hdrs="$(curl -s -D - -o "$TMP/served.html" "$SERVE/$pid/index.html")" printf '%s' "$hdrs" | grep -q '^HTTP/[0-9.]* 200' || fail "serve not 200: $(printf '%s' "$hdrs" | head -1)" csp="$(printf '%s' "$hdrs" | grep -i '^content-security-policy:' || true)" @@ -148,23 +106,20 @@ code="$(curl -s -o /dev/null -w '%{http_code}' "$SERVE/$pid")" [ "$code" = "302" ] || fail "bare root expected 302, got $code" echo " ok: bare root 302" -# 7. Owner-scoping — user2 DELETE on user1's session → 403. -RT2="$(login "$EXE_E2E_USER2" "$EXE_E2E_PASS2" "$JAR2")" -[ -n "$RT2" ] || fail "no post-login requesttoken for user2" -code="$(curl -s -o /dev/null -w '%{http_code}' -c "$JAR2" -b "$JAR2" -H "requesttoken: $RT2" -X DELETE "$MGMT/$pid")" +# 7. Owner-scoping — user2 on user1's session → 403. +code="$(api_code "$U2" "$P2" DELETE "$MGMT/$pid")" [ "$code" = "403" ] || fail "cross-user delete expected 403, got $code" echo " ok: cross-user management is 403" # 8. Dropped multipart part — 1 write declared, 0 file parts → 400, revision unchanged. -code="$(curl -s -o /dev/null -w '%{http_code}' -c "$JAR1" -b "$JAR1" -H "requesttoken: $RT1" \ - -F "revision={\"baseRevision\":1,\"nextRevision\":2,\"writes\":[\"index.html\"],\"deletes\":[],\"assetRefs\":{},\"fixedRefs\":{}}" \ - -X POST "$MGMT/$pid/revisions")" +code="$(api_code "$U1" "$P1" POST "$MGMT/$pid/revisions" \ + -F "revision={\"baseRevision\":1,\"nextRevision\":2,\"writes\":[\"index.html\"],\"deletes\":[],\"assetRefs\":{},\"fixedRefs\":{}}")" [ "$code" = "400" ] || fail "dropped-part revision expected 400, got $code" curl -s "$SERVE/$pid/index.html" | grep -q 'preview-v1' || fail "revision advanced despite dropped part" echo " ok: dropped-part revision rejected (400), revision pointer unchanged" # 9. Owner DELETE → 200, then serve → 404. -code="$(curl -s -o /dev/null -w '%{http_code}' -c "$JAR1" -b "$JAR1" -H "requesttoken: $RT1" -X DELETE "$MGMT/$pid")" +code="$(api_code "$U1" "$P1" DELETE "$MGMT/$pid")" [ "$code" = "200" ] || fail "owner delete expected 200, got $code" code="$(curl -s -o /dev/null -w '%{http_code}' "$SERVE/$pid/index.html")" [ "$code" = "404" ] || fail "serve after delete expected 404, got $code" From b3faa34e4587aa962de9d3cb1c4be960226af2c7 Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Sun, 12 Jul 2026 08:16:33 +0100 Subject: [PATCH 35/43] docs(preview): document core artifact activation testing --- docs/testing-http-preview-core-artifact.md | 73 ++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 docs/testing-http-preview-core-artifact.md diff --git a/docs/testing-http-preview-core-artifact.md b/docs/testing-http-preview-core-artifact.md new file mode 100644 index 0000000..f1d7b16 --- /dev/null +++ b/docs/testing-http-preview-core-artifact.md @@ -0,0 +1,73 @@ +# Testing the HTTP preview with a core pull-request artifact + +The Nextcloud adapter already injects Preview Serving Contract v2 configuration and exposes the management and capability routes. A released editor older than `HttpPreviewProvider` ignores that configuration, so browser activation must be tested against a reproducible static-editor artifact from the target eXeLearning core commit. + +## Build one canonical editor artifact + +From the eXeLearning core checkout: + +```bash +make bundle +make build-static +``` + +Verify that the static distribution contains: + +```text +public/app/workarea/interface/elements/preview/HttpPreviewProvider.js +public/app/workarea/interface/elements/preview/StaticServiceWorkerPreviewProvider.js +public/bundles/preview-fixed-resources.json +``` + +It must not contain the removed `SrcdocPreviewProvider` or `srcdocInliner` files. + +Archive the distribution once and record its SHA-256. Use the same archive in every LMS/CMS integration test so transport behavior cannot drift between host builds. + +## Install it in the Nextcloud development environment + +Replace only the generated embedded-editor distribution used by the development container. Do not commit the generated editor bundle to this application repository. + +Run the browser test with a normal Nextcloud cookie session. Basic-auth API coverage remains useful, but it does not prove that the embedded editor sends the current `requesttoken` or that the iframe is configured correctly. + +Verify this complete flow: + +1. The editor bootstrap injects protocol version 2, management base, serving base, and `requesttoken`. +2. `POST /apps/exelearning/api/preview-session` creates an owner-scoped session. +3. New assets are uploaded once. +4. Revision 1 is published atomically. +5. The iframe loads `/apps/exelearning/preview/{previewId}/index.html`. +6. The iframe sandbox omits `allow-same-origin`. +7. Scriptable serving responses include the sandbox CSP. +8. Editing one page publishes only changed generated documents. +9. Serving requests omit credentials. +10. Management requests retain the Nextcloud cookie and `requesttoken`. +11. A missing or invalid request token is rejected. +12. Session expiry or process cleanup is recovered through a new session after `404`. + +Also cover page navigation, external media, a large single-range asset response, a dropped multipart part, wrong-owner management access, and cleanup. + +## Static Service Worker escape hatch + +An embedded Nextcloud editor must never use the static Service Worker transport. A development-only php-wasm environment may opt in only with both fields: + +```jsonc +{ + "previewTransport": "static-service-worker", + "allowUnsafeEmbeddedPreview": true +} +``` + +`previewTransport` alone is rejected. This mode is same-origin, is not a security sandbox, and must not be enabled in production. + +## Merge evidence + +Record in the PR: + +- core commit SHA; +- editor archive SHA-256; +- Nextcloud and PHP versions; +- browser version; +- captured management and serving requests; +- sandbox and CSP assertions; +- CSRF-negative result; +- proof that unchanged assets were not retransmitted. From a92b3d115bb7885c92144c98185f6da760c9de96 Mon Sep 17 00:00:00 2001 From: erseco Date: Sun, 12 Jul 2026 11:39:21 +0100 Subject: [PATCH 36/43] fix(viewer): stream ZIP fallback in playground --- lib/Service/ZipEntryService.php | 8 +++- tests/Unit/Service/ZipEntryServiceTest.php | 44 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/lib/Service/ZipEntryService.php b/lib/Service/ZipEntryService.php index db55898..7700988 100644 --- a/lib/Service/ZipEntryService.php +++ b/lib/Service/ZipEntryService.php @@ -32,13 +32,17 @@ public function readEntry(File $file, string $entry): ?string { } $localPath = $file->getStorage()->getLocalFile($file->getInternalPath()); - if ($localPath === false || $localPath === null) { + if (!is_string($localPath) || $localPath === '') { return $this->readEntryFromStream($file, $normalized); } $zip = new ZipArchive(); if ($zip->open($localPath) !== true) { - return null; + // Some virtual storage implementations (notably the php-wasm + // Playground filesystem) expose a nominal local path that native + // ZipArchive still cannot open. Treat it like any other non-local + // storage and retry through the portable File::fopen() path. + return $this->readEntryFromStream($file, $normalized); } try { if ($zip->numFiles > self::MAX_ENTRIES) { diff --git a/tests/Unit/Service/ZipEntryServiceTest.php b/tests/Unit/Service/ZipEntryServiceTest.php index e0f792d..2bc43c7 100644 --- a/tests/Unit/Service/ZipEntryServiceTest.php +++ b/tests/Unit/Service/ZipEntryServiceTest.php @@ -44,4 +44,48 @@ public function testRejectsEmptyAndNulTaintedPaths(): void { self::assertNull($this->service->normalizeEntry('')); self::assertNull($this->service->normalizeEntry("a\0b")); } + + public function testReadEntryFallsBackToStreamWhenLocalPathCannotBeOpened(): void { + if (!class_exists('OCP\\Files\\File')) { + eval('namespace OCP\\Files; class File {}'); + } + + $archivePath = tempnam(sys_get_temp_dir(), 'elpx_test_'); + self::assertNotFalse($archivePath); + $zip = new \ZipArchive(); + self::assertTrue($zip->open($archivePath, \ZipArchive::OVERWRITE) === true); + $zip->addFromString('index.html', '

Playground

'); + $zip->close(); + $archive = file_get_contents($archivePath); + @unlink($archivePath); + self::assertIsString($archive); + + $file = new class($archive) extends \OCP\Files\File { + public function __construct( + private readonly string $archive, + ) { + } + + public function getStorage(): object { + return new class { + public function getLocalFile(string $path): string { + return '/virtual/php-wasm/package.elpx'; + } + }; + } + + public function getInternalPath(): string { + return 'files/package.elpx'; + } + + public function fopen(string $mode) { + $stream = fopen('php://temp', 'w+b'); + fwrite($stream, $this->archive); + rewind($stream); + return $stream; + } + }; + + self::assertSame('

Playground

', $this->service->readEntry($file, 'index.html')); + } } From fa1b41ddf0c75cda4ca3e626fe5c29d9c8694c12 Mon Sep 17 00:00:00 2001 From: erseco Date: Sun, 12 Jul 2026 12:00:43 +0100 Subject: [PATCH 37/43] fix(viewer): retain auth in asset fallback --- src/elpx/iframe-renderer.ts | 24 +++++++++++++++++++++++- src/viewer/ElpxViewer.vue | 14 ++++++++------ tests/js/iframe-renderer.test.ts | 16 +++++++++++++++- 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/src/elpx/iframe-renderer.ts b/src/elpx/iframe-renderer.ts index d0c6a01..9da2e61 100644 --- a/src/elpx/iframe-renderer.ts +++ b/src/elpx/iframe-renderer.ts @@ -13,7 +13,7 @@ * by touching the same-origin iframe document. */ -import { buildContentUrl, buildRuntimeUrl } from './paths' +import { buildAssetUrl, buildContentUrl, buildRuntimeUrl } from './paths' /** Opaque, secure sandbox: no allow-same-origin (that absence is the isolation). */ const SECURE_SANDBOX_FLAGS = [ @@ -78,6 +78,13 @@ export interface ContentIframeOptions { title: string } +export interface AssetIframeOptions { + assetBase: string + fileId: number + indexEntry: string + title: string +} + /** * Builds a sandboxed iframe pointed at an already-resolved `src`. * @param src Fully-resolved URL the iframe should load. @@ -137,6 +144,21 @@ export function createPackageIframe(options: IframeOptions): HTMLIFrameElement { ) } +/** + * Builds the authenticated same-origin iframe used when Service Worker + * registration is unavailable. AssetController checks the Nextcloud session, + * so this path must retain `allow-same-origin`; the secure capability-backed + * `/content` route remains the only opaque variant. + * @param options Asset base URL, file id, index entry and accessible title. + */ +export function createAssetIframe(options: AssetIframeOptions): HTMLIFrameElement { + return buildSandboxedIframe( + buildAssetUrl(options.assetBase, options.fileId, options.indexEntry), + options.title, + false, + ) +} + /** * Forces every external (`scheme:` or `//host`) link inside the iframe to * open in a new tab with `noopener noreferrer`, so the Viewer never navigates diff --git a/src/viewer/ElpxViewer.vue b/src/viewer/ElpxViewer.vue index ea831e2..6b30622 100644 --- a/src/viewer/ElpxViewer.vue +++ b/src/viewer/ElpxViewer.vue @@ -44,8 +44,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 { createContentIframe, createPackageIframe, buildSandboxedIframe } from '../elpx/iframe-renderer' -import { ASSET_PREFIX, buildAssetUrl, CONTENT_PREFIX } from '../elpx/paths' +import { createAssetIframe, createContentIframe, createPackageIframe } from '../elpx/iframe-renderer' +import { ASSET_PREFIX, CONTENT_PREFIX } from '../elpx/paths' import { attachMedia, clearOverlays, type EmbedRelayConfig, pingEmbeds, reflowOverlays, startRelay } from '../embed/relay-host' /** @@ -212,10 +212,12 @@ export default defineComponent({ 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', - ) + const iframe = createAssetIframe({ + assetBase: generateUrl(ASSET_PREFIX), + fileId, + indexEntry, + title: this.basename || this.filename || 'package.elpx', + }) slot.appendChild(iframe) this.iframe = iframe }, diff --git a/tests/js/iframe-renderer.test.ts b/tests/js/iframe-renderer.test.ts index f7cf214..0a02846 100644 --- a/tests/js/iframe-renderer.test.ts +++ b/tests/js/iframe-renderer.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from 'vitest' import { buildSandboxedIframe, + createAssetIframe, createContentIframe, createPackageIframe, } from '../../src/elpx/iframe-renderer' -import { CONTENT_PREFIX, RUNTIME_PREFIX } from '../../src/elpx/paths' +import { ASSET_PREFIX, CONTENT_PREFIX, RUNTIME_PREFIX } from '../../src/elpx/paths' describe('buildSandboxedIframe', () => { it('appends the eXeLearning teacher-mode param to the index src', () => { @@ -63,3 +64,16 @@ describe('createPackageIframe (legacy Service Worker path)', () => { expect(iframe.getAttribute('sandbox')).toContain('allow-same-origin') }) }) + +describe('createAssetIframe (authenticated server fallback)', () => { + it('builds a same-origin asset URL so Nextcloud authentication is retained', () => { + const iframe = createAssetIframe({ + assetBase: ASSET_PREFIX, + fileId: 81, + indexEntry: 'index.html', + title: 'pkg', + }) + expect(iframe.src).toContain(`${ASSET_PREFIX}/81/index.html?exe-teacher=1`) + expect(iframe.getAttribute('sandbox')).toContain('allow-same-origin') + }) +}) From ae4f334971c4852adf0bccc513e6b64c6c9d6585 Mon Sep 17 00:00:00 2001 From: erseco Date: Sat, 25 Jul 2026 16:42:41 +0100 Subject: [PATCH 38/43] Replace the preview protocol v2 with the snapshot contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The embedded editor stopped speaking protocol v2 months ago. This app kept emitting `previewHttp` with `protocolVersion: 2` — a key the current editor bundle has zero references to — and never emitted `previewSnapshot`, the one it does read. The opaque preview was therefore unreachable: it failed closed and silently stayed filtered. Verified rather than assumed: a bundle rebuilt from the editor's current branch contains `previewSnapshot`, `managementUrl`, `servingBaseUrl` and `deleteUrlTemplate`, and no `previewHttp` or `protocolVersion` at all. The editor now sends the whole project as one ZIP per opaque refresh, so the layered store (immutable asset keys, incremental revisions, a fixed-resource manifest resolved out of the editor distribution) has nothing left to optimise: - PreviewSnapshotStore + SnapshotArchive replace PreviewSessionStore, PreviewSessionApi, FixedResourceManifest and ApiResult. Archives are vetted entry by entry before anything is written, and the zip-bomb cap is enforced on the real decompressed bytes as they stream out — the sizes in a ZIP's central directory are supplied by whoever built the archive. - The four management operations collapse to POST create/replace and DELETE. - PreviewServer keeps its header, cache, ETag and range policy; it only loses the fixed layer. What survives the simplification, because Nextcloud is the one adapter serving many users off one filesystem: the per-user snapshot cap and the global byte budget, both LRU-evicting, plus the idle TTL. Also drops the parent half of the interactive-video media bridge. Its child (`exe_media_bridge.js`) is not distributed, so `exe_media_host.js` and `exe_media_policy.js` could never attach to anything. The embed relay that makes external video work inside the opaque iframe is untouched. --- appinfo/routes.php | 18 +- docs/preview-serving-contract.md | 386 +++------ docs/testing-http-preview-core-artifact.md | 73 -- lib/AppInfo/Application.php | 32 +- lib/BackgroundJob/PreviewCleanupJob.php | 15 +- lib/Controller/EditorController.php | 35 +- lib/Controller/PreviewController.php | 4 +- lib/Controller/PreviewSessionController.php | 208 +---- lib/Service/Preview/ApiResult.php | 25 - lib/Service/Preview/FixedResourceManifest.php | 159 ---- lib/Service/Preview/PreviewServer.php | 32 +- lib/Service/Preview/PreviewSessionApi.php | 112 --- lib/Service/Preview/PreviewSessionLimits.php | 39 - lib/Service/Preview/PreviewSessionStore.php | 820 ------------------ lib/Service/Preview/PreviewSnapshotLimits.php | 28 + lib/Service/Preview/PreviewSnapshotStore.php | 531 ++++++++++++ lib/Service/Preview/SnapshotArchive.php | 177 ++++ src/embed/exe_media_host.js | 533 ------------ src/embed/exe_media_policy.js | 258 ------ src/embed/relay-host.ts | 31 +- src/types/shims.d.ts | 4 +- src/viewer/ElpxViewer.vue | 6 +- .../Preview/FixedResourceManifestTest.php | 108 --- .../PreviewContractConformanceTest.php | 252 ------ .../Service/Preview/PreviewServerTest.php | 127 +-- .../Service/Preview/PreviewSessionApiTest.php | 151 ---- .../Preview/PreviewSessionStoreTest.php | 551 ------------ .../Preview/PreviewSnapshotStoreTest.php | 280 ++++++ tests/fixtures/preview-contract/vectors.json | 299 ------- tests/js/relay-host.test.ts | 14 +- 30 files changed, 1305 insertions(+), 4003 deletions(-) delete mode 100644 docs/testing-http-preview-core-artifact.md delete mode 100644 lib/Service/Preview/ApiResult.php delete mode 100644 lib/Service/Preview/FixedResourceManifest.php delete mode 100644 lib/Service/Preview/PreviewSessionApi.php delete mode 100644 lib/Service/Preview/PreviewSessionLimits.php delete mode 100644 lib/Service/Preview/PreviewSessionStore.php create mode 100644 lib/Service/Preview/PreviewSnapshotLimits.php create mode 100644 lib/Service/Preview/PreviewSnapshotStore.php create mode 100644 lib/Service/Preview/SnapshotArchive.php delete mode 100644 src/embed/exe_media_host.js delete mode 100644 src/embed/exe_media_policy.js delete mode 100644 tests/Unit/Service/Preview/FixedResourceManifestTest.php delete mode 100644 tests/Unit/Service/Preview/PreviewContractConformanceTest.php delete mode 100644 tests/Unit/Service/Preview/PreviewSessionApiTest.php delete mode 100644 tests/Unit/Service/Preview/PreviewSessionStoreTest.php create mode 100644 tests/Unit/Service/Preview/PreviewSnapshotStoreTest.php delete mode 100644 tests/fixtures/preview-contract/vectors.json diff --git a/appinfo/routes.php b/appinfo/routes.php index 41efaa9..00032f7 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -28,9 +28,9 @@ '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). + // 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}', @@ -51,18 +51,6 @@ '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}', diff --git a/docs/preview-serving-contract.md b/docs/preview-serving-contract.md index 5d3c365..0891974 100644 --- a/docs/preview-serving-contract.md +++ b/docs/preview-serving-contract.md @@ -1,19 +1,21 @@ -# Host-served opaque HTTP preview — serving contract v2 +# Opaque editor preview — Nextcloud adapter -This Nextcloud app is a **preview host** for the eXeLearning editor: it serves -the *editor preview* of untrusted, in-progress author content over HTTP from an -**opaque origin**, isolated from the Nextcloud session. +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 document is the host-side companion to the canonical contract in eXe core: -**`doc/development/preview-serving-contract.md`** (repo `exelearning/exelearning`). -Core is authoritative; if the two disagree, core wins — in particular the sandbox -CSP string must stay **byte-identical** to core's `previewCspHeader()`. +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. -**Protocol version: 2.** v1 (a full SHA-256 manifest + content-addressed blob -diff) is removed, not kept alongside. A create-session response without -`protocolVersion: 2` must surface an error (no silent fallback). +The sandbox CSP string must stay **byte-identical** to eXe core's +`previewCspHeader()`; core is authoritative. -## Why an HTTP transport (and not the Service Worker) +## 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 @@ -23,272 +25,138 @@ 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 editor preview is served from a **separate, -authless, cookieless origin** and never through the Service Worker. +subresources bypass the SW), so the preview is served from an authless, +cookieless route and never through the Service Worker. -## The three layers (why v2) +## The two endpoints -v2 splits a preview into three layers with different lifecycles so a refresh -costs `O(changed documents + new assets)` instead of `O(whole project)`: +| | 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. -| Layer | Contents | Lifecycle | Transferred | -|---|---|---|---| -| **Fixed installation resources** (1) | official libraries, base iDevice runtimes, base theme files, PDF.js, content CSS, logo, fonts | immutable per installed editor version | **never** — served from the installed static editor distribution, gated by a build manifest | -| **Session project assets** (2) | author images/audio/video/PDF — anything with an asset identity | immutable per `assetKey`, whole session | **once per session** | -| **Generated documents** (3) | page HTML, navigation, generated CSS/JS, user theme/iDevice files | change every edit | **only the changed files**, as an atomic revision delta | +## Storage -Classification is by **provenance, not name**: a resource is *fixed* only when the -client resolved it from the installation-immutable editor distribution. Custom -themes, user-installed iDevices and anything embedded in an `.elpx` ride the -session layers. + {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 -## Implementation map +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. -All protocol logic is OCP-free and unit-testable; the controllers are thin -Nextcloud adapters. +## What an archive must survive before extraction -| Concern | Class | -|---|---| -| Isolation policy (CSP, scriptable set, Permissions-Policy, MIME, path normalization) | `lib/Service/Preview/PreviewPolicy.php` | -| Session store (three layers, atomic revisions, budgets, TTL, immutability) | `lib/Service/Preview/PreviewSessionStore.php` | -| Fixed-resource layer (manifest lookup + containment) | `lib/Service/Preview/FixedResourceManifest.php` | -| Serving HTTP policy (headers/CSP/cache tiers/Range/304) | `lib/Service/Preview/PreviewServer.php` | -| Management HTTP policy (ownership gate, status/body mapping) | `lib/Service/Preview/PreviewSessionApi.php` | -| Authless serving controller | `lib/Controller/PreviewController.php` | -| Authenticated management controller | `lib/Controller/PreviewSessionController.php` | -| Idle-TTL cleanup job | `lib/BackgroundJob/PreviewCleanupJob.php` | - -## A. Management API (AUTHENTICATED, owner-scoped) - -Served under the normal authenticated app routes (CSRF **on** — these are called -by the embedded editor same-origin, mirroring `editor#save`; they are **not** -`#[NoCSRFRequired]`). Ownership is scoped to the `IUserSession` user id. - -| Method & path | Body | Success | -|---|---|---| -| `POST /apps/exelearning/api/preview-session` | – | `201 { previewId, protocolVersion: 2, revision: 0, limits }` | -| `POST …/preview-session/{previewId}/assets` | multipart: `assets` (JSON `[{key,size}]`), `files[]` index-aligned | `200 { stored, alreadyStored, rejected }` | -| `POST …/preview-session/{previewId}/revisions` | multipart: `revision` (JSON, below), `files[]` aligned with `writes` | `200 { revision, active: true }` | -| `DELETE …/preview-session/{previewId}` | – | `200 { success: true }` | - -- **Asset keys** match `^[0-9a-fA-F-]{36}@[0-9a-f]{8,64}$` and are **immutable**: - re-uploading an existing key returns it in `alreadyStored` and never replaces - bytes (a replaced author file gets a new key). The server treats the key as an - opaque token — it never hashes asset bytes. Enforced on the buffered bytes; a - declared/actual size mismatch rejects that entry. -- **Revision JSON** (`writes` = paths, index-aligned with `files[]`): - - ```jsonc - { - "baseRevision": 17, // the revision the client believes is active - "nextRevision": 18, // must be baseRevision + 1 - "writes": ["index.html"], // aligned with files[] - "deletes": ["html/old.html"], - "assetRefs": { "content/photo.png": "3f2a…@9c41d2e8" }, // FULL map served path → assetKey - "fixedRefs": { "libs/jquery/jquery.min.js": "libs/jquery/jquery.min.js" } // FULL map served path → fixedResourceId - } - ``` - - Validation order: session exists (`404`) → `baseRevision`/`nextRevision` - consistent else `409 { reason: "revision-conflict", currentRevision }` → every - path normalized/safe else `400` → every `assetRefs` value stored else - `422 { reason: "missing-assets", missing }` → every `fixedRefs` value in the - fixed manifest else `422 { reason: "unknown-fixed-resources", resources }` → - file-count/byte budgets else `413`. -- **Atomicity.** A revision is published by writing its content-addressed blobs, - writing the revision manifest (temp+rename), then swapping the `current` - pointer (temp+rename). A concurrent `GET` reads `current` once then an - immutable manifest, so it observes revision *N* or *N+1*, never a mixture. -- **Budgets & TTL.** 30-min idle TTL, 4 sessions/user, 5000 files/session, 200 - MiB/session, 128 MiB/asset, 2 GiB global (per-user and global caps evict LRU). - -## B. Serving route (AUTHLESS capability URL) +`SnapshotArchive` vets every entry **before** anything is written, then extracts +under a byte budget: -``` -GET /apps/exelearning/preview/{previewId}/{path} -``` +- 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. -- **Authless, cookieless.** The opaque iframe sends no SameSite cookies, so this - route does not depend on the session. It is gated solely on the unguessable - server-minted `previewId` UUID + idle TTL (Nextcloud's cookieless serving - primitive, `#[PublicPage]` + `#[NoCSRFRequired]`). It never emits a session - cookie and always answers `Access-Control-Allow-Origin: *` (sound only because - it is cookieless — never paired with credentials). -- `previewId` must match - `^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`; else 404. -- The bare `/preview/{previewId}` **302-redirects** to `{previewId}/index.html` - (a relative `Location`, correct under any webroot). It never serves index.html - bytes inline — a document served from the bare URL would resolve its relative - subresource references against `preview/` (dropping the id segment) and every - asset would 404. -- **Resolution order** (exact-key against the active revision only): - `documents[path]` → `assets[assetRefs[path]]` → `fixed[fixedRefs[path]]` → 404. - A path only ever names a stored entry; only the server-controlled manifest - `path` reaches the filesystem, contained under the distribution root. -- **Range** on session assets: `Accept-Ranges: bytes`, a satisfiable single - range → `206`, a syntactically valid but unsatisfiable single range (e.g. - `bytes=99-` past EOF) → `416`. A malformed, multi-range or non-`bytes` header - is **ignored** — the server serves a normal `200` full body. **Conditional**: - `ETag: ""`, `If-None-Match` → `304`. - -### Required response headers (on EVERY response, including 404) - -| Header | Value | -| --- | --- | -| `X-Content-Type-Options` | `nosniff` | -| `Referrer-Policy` | `no-referrer` | -| `Permissions-Policy` | `camera=(), microphone=(), geolocation=(), payment=()` | -| `Access-Control-Allow-Origin` | `*` (never with credentials) | -| `Content-Type` | the served file's real MIME (always set explicitly — Nextcloud serves unknown extensions as `text/plain`) | - -`Cache-Control` is **tiered by layer**: - -| Response | Cache-Control | -| --- | --- | -| Generated document (layer 3) | `no-store` | -| Session asset (layer 2) | `no-cache` (+ `ETag`, `If-None-Match` → 304) | -| Fixed resource (layer 1) | `private, max-age=31536000` | -| 404 / errors | `no-store` | - -### Sandbox CSP (every scriptable document type, from every layer) - -On `text/html`, `image/svg+xml`, `application/xml`, `text/xml` and -`application/xhtml+xml` — whether the response resolves from the session or the -fixed layer — add this `Content-Security-Policy` **verbatim** (byte-identical to -eXe core `previewCspHeader()`; `PreviewPolicy::CSP`): +## Bounds -``` -sandbox allow-scripts allow-popups allow-forms; default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; media-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self'; frame-src 'self' https://www.youtube-nocookie.com https://player.vimeo.com; child-src 'self' https://www.youtube-nocookie.com https://player.vimeo.com; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'self' -``` +Nextcloud is the one adapter where these matter beyond a single author, because +one shared instance hosts many users against one filesystem: -The leading `sandbox` directive drops the document into an opaque, unique origin -even when opened top-level (a raw `*.html` **or** `*.svg` in a new tab stays -opaque — an author SVG served without it runs its inline `'; // The resilience shim must be installed before any editor script @@ -299,9 +299,8 @@ public function iframe(): DataDisplayResponse|DataResponse { } /** - * The `previewHttp` block handed to the embedded editor so it can drive the - * HTTP editor-preview transport (serving contract v2). Shape frozen by the - * normalized contract: `{ protocolVersion, managementBaseUrl, servingBaseUrl, + * 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 @@ -330,15 +329,14 @@ public function iframe(): DataDisplayResponse|DataResponse { * happen in practice, so a postMessage CONFIGURE refresh into the iframe is a * future nicety, not a correctness requirement. * - * @return array{protocolVersion:int,managementBaseUrl:string,servingBaseUrl:string,managementHeaders:object} + * @return array{managementUrl:string,servingBaseUrl:string,deleteUrlTemplate:string,managementHeaders:object} */ - private function previewHttpConfig(): array { - $managementBaseUrl = $this->urlGenerator->linkToRoute(Application::APP_ID . '.previewSession.create'); + 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. - $sampleId = '00000000-0000-4000-8000-000000000000'; $sampleUrl = $this->urlGenerator->linkToRoute( Application::APP_ID . '.preview.serveRoot', ['previewId' => $sampleId], @@ -347,10 +345,21 @@ private function previewHttpConfig(): array { ? 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 [ - 'protocolVersion' => 2, - 'managementBaseUrl' => $managementBaseUrl, + 'managementUrl' => $this->urlGenerator->linkToRoute(Application::APP_ID . '.previewSession.create'), 'servingBaseUrl' => $servingBaseUrl, + 'deleteUrlTemplate' => $deleteUrlTemplate, 'managementHeaders' => (object)[ 'requesttoken' => $this->csrfTokenManager->getToken()->getEncryptedValue(), ], diff --git a/lib/Controller/PreviewController.php b/lib/Controller/PreviewController.php index aa7e765..bb43ff4 100644 --- a/lib/Controller/PreviewController.php +++ b/lib/Controller/PreviewController.php @@ -15,8 +15,8 @@ /** * AUTHLESS, cookieless serving endpoint for the eXeLearning **editor preview** * of untrusted, unsaved author content, served from an opaque origin - * (serving contract v2 — docs/preview-serving-contract.md, canonical spec in - * eXe core `doc/development/preview-serving-contract.md`). + * (docs/preview-serving-contract.md, canonical spec in eXe core + * `doc/development/preview-serving-contract.md`). * * Unlike {@see AssetController} (authenticated, same-origin, published content), * this route is a `#[PublicPage]` capability URL: knowledge of the `previewId` diff --git a/lib/Controller/PreviewSessionController.php b/lib/Controller/PreviewSessionController.php index bd69bd7..6984a31 100644 --- a/lib/Controller/PreviewSessionController.php +++ b/lib/Controller/PreviewSessionController.php @@ -4,7 +4,7 @@ namespace OCA\ExeLearning\Controller; -use OCA\ExeLearning\Service\Preview\PreviewSessionApi; +use OCA\ExeLearning\Service\Preview\PreviewSnapshotStore; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -13,128 +13,82 @@ use OCP\IUserSession; /** - * AUTHENTICATED, owner-scoped management API for editor preview sessions - * (serving contract v2). This is the ONLY authenticated surface of the preview - * feature; the bytes are served separately by the authless {@see PreviewController}. + * AUTHENTICATED, owner-scoped management API for the opaque editor preview. * - * Called by the embedded editor same-origin, so — unlike the public serving - * route — normal Nextcloud CSRF protection stays ON (these actions are NOT - * `#[NoCSRFRequired]`), mirroring `editor#save`. Ownership is scoped to the - * authenticated {@see IUserSession} user id. + * This is the ONLY authenticated surface of the preview feature; the bytes are + * served separately by the authless {@see PreviewController}. Called by the + * embedded editor same-origin, so — unlike the public serving route — normal + * Nextcloud CSRF protection stays ON (these actions are NOT `#[NoCSRFRequired]`), + * mirroring `editor#save`. Ownership is scoped to the authenticated + * {@see IUserSession} user id. * - * All protocol semantics live in {@see PreviewSessionApi} (OCP-free); this - * controller only reads the multipart request and the authenticated user, then - * maps the {@see \OCA\ExeLearning\Service\Preview\ApiResult} onto a DataResponse. + * Two endpoints, because the editor sends the whole project each time rather + * than patching it: + * + * POST {basePath}/api/preview-session multipart: snapshot=, + * 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 PreviewSessionApi $api, + private readonly PreviewSnapshotStore $store, ) { parent::__construct($appName, $request); } - /** POST /apps/exelearning/api/preview-session */ - #[NoAdminRequired] - public function create(): DataResponse { - $userId = $this->currentUserId(); - if ($userId === null) { - return $this->unauthenticated(); - } - $result = $this->api->create($userId); - return new DataResponse($result->body, $result->status); - } - /** - * POST /apps/exelearning/api/preview-session/{previewId}/assets + * POST {basePath}/api/preview-session — publish a whole-project snapshot. * - * multipart: `assets` = JSON `[{ key, size }]`, `files[]` index-aligned. + * `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 uploadAssets(string $previewId): DataResponse { + public function create(): DataResponse { $userId = $this->currentUserId(); if ($userId === null) { return $this->unauthenticated(); } - $declared = $this->decodeJsonParam('assets'); - $files = $this->uploadedFileBytes('files'); - $entries = []; - foreach ($declared as $index => $meta) { - if (!is_array($meta)) { - continue; - } - $entries[] = [ - 'key' => (string)($meta['key'] ?? ''), - 'declaredSize' => (int)($meta['size'] ?? -1), - 'bytes' => $files[$index] ?? '', - ]; + $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); } - $result = $this->api->uploadAssets($previewId, $userId, $entries); - return new DataResponse($result->body, $result->status); - } - /** - * POST /apps/exelearning/api/preview-session/{previewId}/revisions - * - * multipart: `revision` = JSON `{ baseRevision, nextRevision, writes:[paths], - * deletes, assetRefs, fixedRefs }`, `files[]` index-aligned with `writes`. - */ - #[NoAdminRequired] - public function publishRevision(string $previewId): DataResponse { - $userId = $this->currentUserId(); - if ($userId === null) { - return $this->unauthenticated(); - } - $revision = $this->decodeJsonParam('revision'); - $parts = $this->uploadedFileParts('files'); - $writePaths = is_array($revision['writes'] ?? null) ? array_values($revision['writes']) : []; + $previewId = $this->request->getParam('previewId'); + $previewId = is_string($previewId) && $previewId !== '' ? $previewId : null; - // Strict alignment: every declared write MUST have a healthy uploaded part - // (present, UPLOAD_ERR_OK, readable). A dropped/failed/unreadable part — or - // a part/write count mismatch — rejects the ENTIRE batch with 400 before - // any staging, so a silently-empty document (`sha1('')`, size 0) can never - // be published. The revision pointer is untouched; a subsequent GET keeps - // serving the previous revision. - if (count($parts) !== count($writePaths)) { - return new DataResponse( - ['error' => 'Revision upload incomplete: ' . count($writePaths) - . ' write(s) declared but ' . count($parts) . ' file part(s) received'], - Http::STATUS_BAD_REQUEST, - ); - } - $writes = []; - foreach ($writePaths as $index => $path) { - if (!$parts[$index]['ok']) { - return new DataResponse( - ['error' => 'Revision upload incomplete: missing or unreadable file part for write #' . $index], - Http::STATUS_BAD_REQUEST, - ); - } - $writes[] = ['path' => (string)$path, 'bytes' => $parts[$index]['bytes']]; + $result = $this->store->replace($userId, $upload['tmp_name'], $previewId); + if (isset($result['error'])) { + return new DataResponse(['error' => $result['error']], (int)$result['status']); } - $meta = [ - 'baseRevision' => (int)($revision['baseRevision'] ?? -1), - 'nextRevision' => (int)($revision['nextRevision'] ?? -1), - 'writes' => $writes, - 'deletes' => is_array($revision['deletes'] ?? null) ? array_values($revision['deletes']) : [], - 'assetRefs' => is_array($revision['assetRefs'] ?? null) ? $revision['assetRefs'] : [], - 'fixedRefs' => is_array($revision['fixedRefs'] ?? null) ? $revision['fixedRefs'] : [], - ]; - $result = $this->api->publishRevision($previewId, $userId, $meta); - return new DataResponse($result->body, $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 /apps/exelearning/api/preview-session/{previewId} */ + /** DELETE {basePath}/api/preview-session/{previewId} */ #[NoAdminRequired] public function delete(string $previewId): DataResponse { $userId = $this->currentUserId(); if ($userId === null) { return $this->unauthenticated(); } - $result = $this->api->delete($previewId, $userId); - return new DataResponse($result->body, $result->status); + if (!$this->store->delete($previewId, $userId)) { + return new DataResponse(['error' => 'Preview snapshot not found'], Http::STATUS_NOT_FOUND); + } + return new DataResponse([], Http::STATUS_OK); } private function currentUserId(): ?string { @@ -145,76 +99,4 @@ private function currentUserId(): ?string { private function unauthenticated(): DataResponse { return new DataResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); } - - /** - * Decode a JSON-string form field into an array (empty array on absence or - * malformed JSON). - * - * @return array - */ - private function decodeJsonParam(string $name): array { - $raw = $this->request->getParam($name); - if (!is_string($raw) || $raw === '') { - return []; - } - $decoded = json_decode($raw, true); - return is_array($decoded) ? $decoded : []; - } - - /** - * Read the `files[]` multipart parts (index-aligned) into a list of byte - * strings, mapping a missing/failed/unreadable part to an empty string. Used - * by the asset path, where the store rejects a dropped part via its - * declared/actual size guard. - * - * @return list - */ - private function uploadedFileBytes(string $field): array { - return array_map( - static fn (array $part): string => $part['bytes'], - $this->uploadedFileParts($field), - ); - } - - /** - * Read the `files[]` multipart parts (index-aligned) with their upload - * health, so a caller can reject a batch when a part is missing, errored or - * unreadable. Handles both the array shape (`files[]`, multiple parts) and the - * scalar shape (a single part). - * - * A part is `ok` only when `$_FILES[...]['error']` is `UPLOAD_ERR_OK`, the - * `tmp_name` is a genuine uploaded file, and its bytes read back. A genuinely - * empty but successfully uploaded file is `ok` with empty bytes; a - * dropped/failed/unreadable part is `ok=false` — the distinction the revision - * path needs and that a plain byte string cannot carry. - * - * @return list - */ - private function uploadedFileParts(string $field): array { - $uploaded = $this->request->getUploadedFile($field); - if (!is_array($uploaded) || !isset($uploaded['tmp_name'])) { - return []; - } - $tmpNames = $uploaded['tmp_name']; - $errors = $uploaded['error'] ?? UPLOAD_ERR_NO_FILE; - if (!is_array($tmpNames)) { - $tmpNames = [$tmpNames]; - $errors = [$errors]; - } - $parts = []; - foreach ($tmpNames as $index => $tmpName) { - $error = is_array($errors) ? ($errors[$index] ?? UPLOAD_ERR_NO_FILE) : $errors; - if ((int)$error !== UPLOAD_ERR_OK || !is_string($tmpName) || $tmpName === '' || !is_uploaded_file($tmpName)) { - $parts[] = ['ok' => false, 'bytes' => '']; - continue; - } - $content = file_get_contents($tmpName); - if ($content === false) { - $parts[] = ['ok' => false, 'bytes' => '']; - continue; - } - $parts[] = ['ok' => true, 'bytes' => $content]; - } - return $parts; - } } diff --git a/lib/Service/Preview/ApiResult.php b/lib/Service/Preview/ApiResult.php deleted file mode 100644 index a8c7297..0000000 --- a/lib/Service/Preview/ApiResult.php +++ /dev/null @@ -1,25 +0,0 @@ - $body JSON response body. - */ - public function __construct( - public readonly int $status, - public readonly array $body, - ) { - } -} diff --git a/lib/Service/Preview/FixedResourceManifest.php b/lib/Service/Preview/FixedResourceManifest.php deleted file mode 100644 index 9ec7615..0000000 --- a/lib/Service/Preview/FixedResourceManifest.php +++ /dev/null @@ -1,159 +0,0 @@ - ['path' => string, 'size' => int]` map, or null until - * first access. An empty map means "manifest absent or unreadable". - * - * @var array|null - */ - private ?array $resources = null; - - /** - * @param string $distRoot Absolute path to the installed editor distribution - * root (e.g. `/js/editor`). Manifest `path` - * values are resolved relative to it. - * @param (callable(string):(string|false))|null $reader File reader (defaults - * to file_get_contents). - * @param (callable(string):bool)|null $exists is_file probe (defaults to is_file). - */ - public function __construct( - private readonly string $distRoot, - ?callable $reader = null, - ?callable $exists = null, - ) { - $this->reader = $reader ?? static fn (string $path): string|false => @file_get_contents($path); - $this->exists = $exists ?? static fn (string $path): bool => is_file($path); - } - - /** Whether $id is enumerated by the manifest. */ - public function has(string $id): bool { - return isset($this->load()[$id]); - } - - /** - * Read the file backing $id, or null when the id is unknown, the file is - * missing/unreadable, or the resolved path escapes the distribution root. - * - * @return array{bytes:string,size:int}|null - */ - public function get(string $id): ?array { - $entry = $this->load()[$id] ?? null; - if ($entry === null) { - return null; - } - $resolved = $this->resolveContained($entry['path']); - if ($resolved === null || !($this->exists)($resolved)) { - return null; - } - $bytes = ($this->reader)($resolved); - if ($bytes === false) { - return null; - } - return ['bytes' => $bytes, 'size' => strlen($bytes)]; - } - - /** - * Parse the manifest once. Any structural problem (missing file, invalid - * JSON, wrong shape) yields an empty map — the fixed layer is disabled, never - * an error. - * - * @return array - */ - private function load(): array { - if ($this->resources !== null) { - return $this->resources; - } - $this->resources = []; - $manifestPath = $this->distRoot . '/' . self::MANIFEST_RELATIVE_PATH; - if (!($this->exists)($manifestPath)) { - return $this->resources; - } - $raw = ($this->reader)($manifestPath); - if ($raw === false) { - return $this->resources; - } - $decoded = json_decode($raw, true); - if (!is_array($decoded) || !isset($decoded['resources']) || !is_array($decoded['resources'])) { - return $this->resources; - } - foreach ($decoded['resources'] as $id => $entry) { - if (!is_string($id) || !is_array($entry) || !isset($entry['path']) || !is_string($entry['path'])) { - continue; - } - $this->resources[$id] = [ - 'path' => $entry['path'], - 'size' => isset($entry['size']) && is_int($entry['size']) ? $entry['size'] : 0, - ]; - } - return $this->resources; - } - - /** - * Resolve a manifest-declared path under the distribution root and confirm - * it stays contained (rejects `..`, absolute paths and symlink escapes). - * Returns the absolute path or null. - */ - private function resolveContained(string $relative): ?string { - if (str_contains($relative, "\0")) { - return null; - } - $normalized = PreviewPolicy::normalizePath($relative); - if ($normalized === null) { - return null; - } - $candidate = $this->distRoot . '/' . $normalized; - $realRoot = realpath($this->distRoot); - $realCandidate = realpath($candidate); - if ($realRoot === false || $realCandidate === false) { - return null; - } - $prefix = rtrim($realRoot, '/') . '/'; - if ($realCandidate !== $realRoot && !str_starts_with($realCandidate, $prefix)) { - return null; - } - return $realCandidate; - } -} diff --git a/lib/Service/Preview/PreviewServer.php b/lib/Service/Preview/PreviewServer.php index 62aff02..ed56492 100644 --- a/lib/Service/Preview/PreviewServer.php +++ b/lib/Service/Preview/PreviewServer.php @@ -8,20 +8,19 @@ * HTTP serving policy for the authless preview capability URL * (`GET {basePath}/preview/{previewId}/{path}`). * - * Pure logic over {@see PreviewSessionStore} + {@see FixedResourceManifest}: - * it validates the capability id, resolves the three layers, and applies the - * contract's response policy — the hardening header set on EVERY response - * (404s included), the tiered `Cache-Control`, `ETag`/`If-None-Match` → 304 and - * single-range 206/416 on assets, and the sandbox-first CSP on every scriptable - * document type from any layer. It returns a {@see PreviewResponse} value - * object so the whole policy is unit-testable and vector-replayable without a - * Nextcloud server; {@see \OCA\ExeLearning\Controller\PreviewController} is the - * thin adapter onto `DataDisplayResponse`. + * Pure logic over {@see PreviewSnapshotStore}: it validates the capability id, + * resolves the path inside the snapshot, and applies the contract's response + * policy — the hardening header set on EVERY response (404s included), the + * tiered `Cache-Control`, `ETag`/`If-None-Match` → 304 and single-range 206/416 + * on assets, and the sandbox-first CSP on every scriptable document type. It + * returns a {@see PreviewResponse} value object so the whole policy is + * unit-testable without a Nextcloud server; + * {@see \OCA\ExeLearning\Controller\PreviewController} is the thin adapter onto + * `DataDisplayResponse`. */ final class PreviewServer { public function __construct( - private readonly PreviewSessionStore $store, - private readonly FixedResourceManifest $fixed, + private readonly PreviewSnapshotStore $store, ) { } @@ -35,14 +34,13 @@ public function serve(string $previewId, string $rawPath, ?string $ifNoneMatch = if (!PreviewPolicy::isValidPreviewId($previewId)) { return $this->notFound(); } - $file = $this->store->resolve($previewId, $rawPath, $this->fixed); + $file = $this->store->resolve($previewId, $rawPath); if ($file === null) { return $this->notFound(); } return match ($file['kind']) { 'asset' => $this->serveAsset($file, $ifNoneMatch, $range), - 'fixed' => $this->serveBytes($file, 'private, max-age=31536000'), default => $this->serveBytes($file, 'no-store'), }; } @@ -58,7 +56,7 @@ public function serve(string $previewId, string $rawPath, ?string $ifNoneMatch = * them onto the correct directory. The `Location` is relative so it stays * correct under any Nextcloud webroot (it resolves against the request URI, * `{servingBase}/{previewId}`) without the store needing a URL generator, and - * the whole decision stays OCP-free and vector-replayable. Contract v2.1 §4. + * the whole decision stays OCP-free. */ public function serveRoot(string $previewId): PreviewResponse { if (!PreviewPolicy::isValidPreviewId($previewId)) { @@ -72,8 +70,8 @@ public function serveRoot(string $previewId): PreviewResponse { } /** - * Document (layer 3, `no-store`) or fixed resource (layer 1, immutable and - * cacheable). Both may be scriptable and then carry the sandbox CSP. + * A scriptable document: rewritten on every refresh, so `no-store`, and it + * carries the sandbox CSP. * * @param array{contentType:string,isScriptable:bool,bytes?:string} $file */ @@ -88,7 +86,7 @@ private function serveBytes(array $file, string $cacheControl): PreviewResponse } /** - * Session project asset (layer 2): revalidated (`no-cache`) with an ETag, + * A non-scriptable asset: revalidated (`no-cache`) with an ETag, * `Accept-Ranges: bytes`, `If-None-Match` → 304 and single-range 206/416. * * @param array{contentType:string,isScriptable:bool,filePath:string,size:int,etag:string} $file diff --git a/lib/Service/Preview/PreviewSessionApi.php b/lib/Service/Preview/PreviewSessionApi.php deleted file mode 100644 index 0c5abb0..0000000 --- a/lib/Service/Preview/PreviewSessionApi.php +++ /dev/null @@ -1,112 +0,0 @@ -store->create($ownerUserId); - return new ApiResult(201, [ - 'previewId' => $previewId, - 'protocolVersion' => self::PROTOCOL_VERSION, - 'revision' => 0, - 'limits' => $this->store->limits()->forWire(), - ]); - } - - /** - * Upload session assets (immutable per key). - * - * @param list $entries - */ - public function uploadAssets(string $previewId, string $ownerUserId, array $entries): ApiResult { - $gate = $this->ownershipGate($previewId, $ownerUserId); - if ($gate !== null) { - return $gate; - } - $result = $this->store->storeAssets($previewId, $entries); - return new ApiResult(200, $result); - } - - /** - * Publish a revision (atomic, optimistic-concurrency). - * - * @param array{baseRevision:int,nextRevision:int,writes:list,deletes:list,assetRefs:array,fixedRefs:array} $meta - */ - public function publishRevision(string $previewId, string $ownerUserId, array $meta): ApiResult { - $gate = $this->ownershipGate($previewId, $ownerUserId); - if ($gate !== null) { - return $gate; - } - $result = $this->store->applyRevision($previewId, $meta, $this->fixed); - return match ($result['status']) { - 200 => new ApiResult(200, ['revision' => $result['revision'], 'active' => true]), - 409 => new ApiResult(409, ['reason' => 'revision-conflict', 'currentRevision' => $result['currentRevision']]), - 422 => new ApiResult(422, $this->unprocessableBody($result)), - 413 => new ApiResult(413, ['error' => $result['message'] ?? 'Preview storage budget exceeded']), - 500 => new ApiResult(500, ['error' => $result['message'] ?? 'Failed to persist revision']), - default => new ApiResult(400, ['error' => $result['message'] ?? 'Bad request']), - }; - } - - /** Delete a session. */ - public function delete(string $previewId, string $ownerUserId): ApiResult { - $gate = $this->ownershipGate($previewId, $ownerUserId); - if ($gate !== null) { - return $gate; - } - $this->store->delete($previewId); - return new ApiResult(200, ['success' => true]); - } - - /** - * 404 for a missing session, 403 for someone else's, null when the caller - * owns it. - */ - private function ownershipGate(string $previewId, string $ownerUserId): ?ApiResult { - $owner = $this->store->ownerOf($previewId); - if ($owner === null) { - return new ApiResult(404, ['error' => 'Preview session not found']); - } - if ($owner !== $ownerUserId) { - return new ApiResult(403, ['error' => 'Access denied']); - } - return null; - } - - /** - * @param array{reason?:string,missing?:list,resources?:list} $result - * @return array - */ - private function unprocessableBody(array $result): array { - if (($result['reason'] ?? '') === 'missing-assets') { - return ['reason' => 'missing-assets', 'missing' => $result['missing'] ?? []]; - } - return ['reason' => 'unknown-fixed-resources', 'resources' => $result['resources'] ?? []]; - } -} diff --git a/lib/Service/Preview/PreviewSessionLimits.php b/lib/Service/Preview/PreviewSessionLimits.php deleted file mode 100644 index 3ea4502..0000000 --- a/lib/Service/Preview/PreviewSessionLimits.php +++ /dev/null @@ -1,39 +0,0 @@ - $this->maxFilesPerSession, - 'maxBytesPerSession' => $this->maxBytesPerSession, - 'maxAssetBytes' => $this->maxAssetBytes, - 'recommendedBatchBytes' => $this->recommendedBatchBytes, - ]; - } -} diff --git a/lib/Service/Preview/PreviewSessionStore.php b/lib/Service/Preview/PreviewSessionStore.php deleted file mode 100644 index 257fc62..0000000 --- a/lib/Service/Preview/PreviewSessionStore.php +++ /dev/null @@ -1,820 +0,0 @@ -clock = $clock ?? static fn (): int => time(); - } - - public function limits(): PreviewSessionLimits { - return $this->limits; - } - - // ========================================================================= - // Session lifecycle - // ========================================================================= - - /** - * Create a new session owned by $ownerUserId and return its capability id. - * Enforces the per-user session cap by evicting that user's least-recently - * accessed session first. - */ - public function create(string $ownerUserId): string { - $this->ensureDir($this->root); - $this->enforceUserSessionCap($ownerUserId); - - $previewId = $this->generateUuidV4(); - $dir = $this->sessionDir($previewId); - $this->ensureDir($dir); - $this->ensureDir($dir . '/assets'); - $this->ensureDir($dir . '/documents'); - $this->ensureDir($dir . '/revisions'); - $this->writeFileAtomic($dir . '/meta.json', json_encode([ - 'ownerUserId' => $ownerUserId, - 'createdAt' => ($this->clock)(), - ], JSON_UNESCAPED_SLASHES)); - $this->touch($previewId); - return $previewId; - } - - /** Whether the session exists on disk (independent of TTL). */ - public function exists(string $previewId): bool { - if (!PreviewPolicy::isValidPreviewId($previewId)) { - return false; - } - $meta = $this->sessionDir($previewId) . '/meta.json'; - clearstatcache(true, $meta); - return is_file($meta); - } - - /** The owner user id of a session, or null when it does not exist. */ - public function ownerOf(string $previewId): ?string { - $meta = $this->readMeta($previewId); - return $meta['ownerUserId'] ?? null; - } - - /** Delete a session and everything under it. Returns whether it existed. */ - public function delete(string $previewId): bool { - if (!$this->exists($previewId)) { - return false; - } - $this->removeTree($this->sessionDir($previewId)); - return true; - } - - /** The active revision number, or 0 when nothing has been published yet. */ - public function activeRevision(string $previewId): int { - $raw = @file_get_contents($this->sessionDir($previewId) . '/current'); - if ($raw === false) { - return 0; - } - $n = (int)trim($raw); - return $n > 0 ? $n : 0; - } - - // ========================================================================= - // TTL / cleanup - // ========================================================================= - - /** Whether a session has been idle longer than the TTL. */ - public function isExpired(string $previewId): bool { - $marker = $this->sessionDir($previewId) . '/.accessed'; - clearstatcache(true, $marker); - $accessed = @filemtime($marker); - if ($accessed !== false) { - return (($this->clock)() - $accessed) > $this->limits->ttlSeconds; - } - // A missing/unreadable `.accessed` marker must NOT make a session - // immortal — that would keep it counting against the global byte budget - // forever and never let sweepExpired reclaim it. Fall back to meta.json - // createdAt as the age clock; if meta.json is missing/corrupt too, the - // directory is unusable (it can neither be owned nor served), so treat it - // as expired and let sweepExpired reclaim it. globalBytes() is recomputed - // from the surviving session directories on every call, so removing the - // directory automatically reconciles the accounting. - $createdAt = $this->createdAt($previewId); - if ($createdAt === null) { - return true; - } - return (($this->clock)() - $createdAt) > $this->limits->ttlSeconds; - } - - /** - * Remove every session idle longer than the TTL. Returns the count swept. - * Called by the background cleanup job and opportunistically on access. - */ - public function sweepExpired(): int { - $swept = 0; - foreach ($this->listSessionIds() as $previewId) { - if ($this->isExpired($previewId)) { - $this->removeTree($this->sessionDir($previewId)); - $swept++; - } - } - return $swept; - } - - // ========================================================================= - // Assets (layer 2) - // ========================================================================= - - /** - * Store uploaded assets. Keys are immutable: an existing key is reported in - * `alreadyStored` and its bytes are NOT replaced. Per-entry failures land in - * `rejected` with a reason. Budgets are enforced on the actual buffered - * bytes; a declared/actual size mismatch rejects that entry. - * - * @param list $entries - * @return array{stored:list,alreadyStored:list,rejected:list} - */ - public function storeAssets(string $previewId, array $entries): array { - $assetDir = $this->sessionDir($previewId) . '/assets'; - $this->ensureDir($assetDir); - clearstatcache(); - - $stored = []; - $alreadyStored = []; - $rejected = []; - - $sessionBytes = $this->sessionBytes($previewId); - $globalBytes = $this->globalBytes(); - - foreach ($entries as $entry) { - $key = $entry['key'] ?? ''; - $bytes = $entry['bytes'] ?? ''; - $declared = $entry['declaredSize'] ?? -1; - $size = strlen($bytes); - - if (!PreviewPolicy::isValidAssetKey($key)) { - $rejected[] = ['key' => $key, 'reason' => 'invalid-key']; - continue; - } - $target = $assetDir . '/' . sha1($key); - if (is_file($target)) { - $alreadyStored[] = $key; - continue; - } - if ($declared !== $size) { - $rejected[] = ['key' => $key, 'reason' => 'size-mismatch']; - continue; - } - if ($size > $this->limits->maxAssetBytes) { - $rejected[] = ['key' => $key, 'reason' => 'asset-too-large']; - continue; - } - if ($sessionBytes + $size > $this->limits->maxBytesPerSession) { - $rejected[] = ['key' => $key, 'reason' => 'session-budget-exceeded']; - continue; - } - $globalBytes = $this->evictOthersForBudget($previewId, $size, $globalBytes); - if ($globalBytes === null) { - $rejected[] = ['key' => $key, 'reason' => 'global-budget-exceeded']; - $globalBytes = $this->globalBytes(); - continue; - } - if (!$this->writeBlobAtomic($assetDir, sha1($key), $bytes)) { - clearstatcache(true, $target); - if (is_file($target)) { - // The key appeared concurrently (immutability race): the bytes - // ARE present, so report it as already stored. - $alreadyStored[] = $key; - } else { - // Genuine write failure (disk full / unwritable dir): the bytes - // are NOT on disk. Reject it so the client leaves the key - // un-uploaded — a later revision referencing it then gets a - // 422 missing-assets and the client re-uploads. Reporting - // alreadyStored here would strand the asset as a permanent 404. - $rejected[] = ['key' => $key, 'reason' => 'write-failed']; - } - continue; - } - $sessionBytes += $size; - $globalBytes += $size; - $stored[] = $key; - } - - if ($stored !== []) { - $this->touch($previewId); - } - return ['stored' => $stored, 'alreadyStored' => $alreadyStored, 'rejected' => $rejected]; - } - - // ========================================================================= - // Revisions (layer 3 publication) - // ========================================================================= - - /** - * Publish a revision. Validation order per the contract: revision check - * (409) → path normalization (400) → asset existence (422 missing-assets) → - * fixed-resource existence (422 unknown-fixed-resources) → file-count / byte - * budgets (413). The publish itself is an atomic blob-write → manifest-write - * → pointer-swap under an advisory lock. - * - * A genuine document blob-write failure aborts the publish with `500` before - * the pointer swap, leaving the previously active revision intact. - * - * @param array{baseRevision:int,nextRevision:int,writes:list,deletes:list,assetRefs:array,fixedRefs:array} $meta - * @return array{status:int,revision?:int,currentRevision?:int,reason?:string,missing?:list,resources?:list,message?:string} - */ - public function applyRevision(string $previewId, array $meta, FixedResourceManifest $fixed): array { - $dir = $this->sessionDir($previewId); - $lock = $this->acquireLock($dir); - - try { - clearstatcache(); - $active = $this->activeRevision($previewId); - $base = $meta['baseRevision'] ?? -1; - $next = $meta['nextRevision'] ?? -1; - if ($base !== $active || $next !== $active + 1) { - return ['status' => 409, 'currentRevision' => $active]; - } - - // 2. Normalize every client-supplied path. - $writes = []; - foreach ($meta['writes'] ?? [] as $write) { - $normalized = PreviewPolicy::normalizePath($write['path'] ?? ''); - if ($normalized === null) { - return ['status' => 400, 'message' => 'Unsafe path in writes: ' . ($write['path'] ?? '')]; - } - $writes[$normalized] = $write['bytes'] ?? ''; - } - $deletes = []; - foreach ($meta['deletes'] ?? [] as $rawPath) { - $normalized = PreviewPolicy::normalizePath($rawPath); - if ($normalized === null) { - return ['status' => 400, 'message' => 'Unsafe path in deletes: ' . $rawPath]; - } - $deletes[] = $normalized; - } - $assetRefs = []; - foreach (($meta['assetRefs'] ?? []) as $rawPath => $key) { - $normalized = PreviewPolicy::normalizePath((string)$rawPath); - if ($normalized === null) { - return ['status' => 400, 'message' => 'Unsafe path in assetRefs: ' . $rawPath]; - } - $assetRefs[$normalized] = (string)$key; - } - $fixedRefs = []; - foreach (($meta['fixedRefs'] ?? []) as $rawPath => $id) { - $normalized = PreviewPolicy::normalizePath((string)$rawPath); - if ($normalized === null) { - return ['status' => 400, 'message' => 'Unsafe path in fixedRefs: ' . $rawPath]; - } - $fixedRefs[$normalized] = (string)$id; - } - - // 3. Every referenced asset must already be stored. - $missing = []; - foreach (array_unique(array_values($assetRefs)) as $key) { - if (!is_file($dir . '/assets/' . sha1($key))) { - $missing[] = $key; - } - } - if ($missing !== []) { - return ['status' => 422, 'reason' => 'missing-assets', 'missing' => array_values($missing)]; - } - - // 4. Every referenced fixed resource must be manifest-listed. - $unknown = []; - foreach (array_unique(array_values($fixedRefs)) as $id) { - if (!$fixed->has($id)) { - $unknown[] = $id; - } - } - if ($unknown !== []) { - return ['status' => 422, 'reason' => 'unknown-fixed-resources', 'resources' => array_values($unknown)]; - } - - // 5. Compute the post-delta document set (deletes first, then writes) - // and enforce the budgets before touching disk. - $documents = $active > 0 ? ($this->readRevision($previewId, $active)['documents'] ?? []) : []; - foreach ($deletes as $del) { - if (!array_key_exists($del, $writes)) { - unset($documents[$del]); - } - } - foreach ($writes as $path => $bytes) { - $documents[$path] = ['hash' => sha1($bytes), 'size' => strlen($bytes)]; - } - $documentBytes = 0; - foreach ($documents as $entry) { - $documentBytes += $entry['size']; - } - $fileCount = count($documents) + count($assetRefs) + count($fixedRefs); - if ($fileCount > $this->limits->maxFilesPerSession) { - return ['status' => 413, 'message' => 'Too many files (max ' . $this->limits->maxFilesPerSession . ')']; - } - $assetBytes = $this->assetBytes($previewId); - if ($documentBytes + $assetBytes > $this->limits->maxBytesPerSession) { - return ['status' => 413, 'message' => 'Session over byte budget']; - } - $previousDocumentBytes = $active > 0 ? ($this->readRevision($previewId, $active)['documentBytes'] ?? 0) : 0; - $byteDelta = $documentBytes - $previousDocumentBytes; - if ($byteDelta > 0 && $this->evictOthersForBudget($previewId, $byteDelta, $this->globalBytes()) === null) { - return ['status' => 413, 'message' => 'Preview storage budget exceeded']; - } - - // 6. Publish. Write new content-addressed blobs, then the revision - // manifest, then atomically swap the pointer. A genuine blob-write - // failure (disk full / unwritable) must abort BEFORE the manifest - // and pointer swap, or the active revision advances onto a document - // whose bytes are absent (a silent permanent 404). This mirrors the - // asset path's write-failed rigor. An "already present" blob is not - // a failure — content-addressing dedups identical bytes. - foreach ($writes as $bytes) { - $hash = sha1($bytes); - $target = $dir . '/documents/' . $hash; - if (!$this->writeBlobAtomic($dir . '/documents', $hash, $bytes)) { - clearstatcache(true, $target); - if (!is_file($target)) { - return ['status' => 500, 'message' => 'Failed to persist revision document']; - } - } - } - $this->writeFileAtomic($dir . '/revisions/' . $next . '.json', json_encode([ - 'assetRefs' => (object)$assetRefs, - 'fixedRefs' => (object)$fixedRefs, - 'documents' => (object)$documents, - 'documentBytes' => $documentBytes, - ], JSON_UNESCAPED_SLASHES)); - $this->writeFileAtomic($dir . '/current', (string)$next); - - // Prune superseded revisions AFTER the pointer swap so that (a) new - // readers already resolve `next` and (b) physical disk is bounded by the - // active revision — without this, each published revision's unique - // document blobs accumulate forever (an authenticated disk-fill DoS), - // since the budget only ever counts the ACTIVE revision. Assets are - // shared/immutable across revisions and are never pruned here. This runs - // under the same exclusive flock as the publish (single writer). A rare - // GET still pinned to the just-superseded revision may 404 and re-sync — - // the contract's designed recovery. - $this->pruneSupersededRevisions($previewId, $next); - $this->touch($previewId); - - return ['status' => 200, 'revision' => $next]; - } finally { - $this->releaseLock($lock); - } - } - - // ========================================================================= - // Serving (three-layer resolution) - // ========================================================================= - - /** - * Resolve a served path against the active revision, touching the idle-TTL - * clock. Returns a descriptor, or null for a missing/expired session, an - * unpublished session (revision 0), an unsafe path, or a miss. - * - * @return array{kind:string,contentType:string,isScriptable:bool,bytes?:string,filePath?:string,size?:int,etag?:string}|null - */ - public function resolve(string $previewId, string $rawPath, FixedResourceManifest $fixed): ?array { - if (!$this->exists($previewId)) { - return null; - } - if ($this->isExpired($previewId)) { - $this->removeTree($this->sessionDir($previewId)); - return null; - } - $this->touch($previewId); - - $active = $this->activeRevision($previewId); - if ($active === 0) { - return null; - } - $path = PreviewPolicy::normalizePath($rawPath); - if ($path === null) { - return null; - } - $revision = $this->readRevision($previewId, $active); - if ($revision === null) { - return null; - } - $dir = $this->sessionDir($previewId); - $contentType = PreviewPolicy::mimeForPath($path); - $isScriptable = PreviewPolicy::isScriptable($contentType); - - $documents = $revision['documents'] ?? []; - if (isset($documents[$path])) { - $bytes = @file_get_contents($dir . '/documents/' . $documents[$path]['hash']); - if ($bytes === false) { - return null; - } - return ['kind' => 'document', 'contentType' => $contentType, 'isScriptable' => $isScriptable, 'bytes' => $bytes]; - } - - $assetRefs = $revision['assetRefs'] ?? []; - if (isset($assetRefs[$path])) { - $key = $assetRefs[$path]; - $file = $dir . '/assets/' . sha1($key); - clearstatcache(true, $file); - if (is_file($file)) { - return [ - 'kind' => 'asset', - 'contentType' => $contentType, - 'isScriptable' => $isScriptable, - 'filePath' => $file, - 'size' => (int)filesize($file), - 'etag' => $key, - ]; - } - } - - $fixedRefs = $revision['fixedRefs'] ?? []; - if (isset($fixedRefs[$path])) { - $resource = $fixed->get($fixedRefs[$path]); - if ($resource !== null) { - return [ - 'kind' => 'fixed', - 'contentType' => $contentType, - 'isScriptable' => $isScriptable, - 'bytes' => $resource['bytes'], - ]; - } - } - - return null; - } - - // ========================================================================= - // Internals - // ========================================================================= - - private function sessionDir(string $previewId): string { - return $this->root . '/' . $previewId; - } - - /** @return array|null */ - private function readMeta(string $previewId): ?array { - if (!PreviewPolicy::isValidPreviewId($previewId)) { - return null; - } - $raw = @file_get_contents($this->sessionDir($previewId) . '/meta.json'); - if ($raw === false) { - return null; - } - $decoded = json_decode($raw, true); - return is_array($decoded) ? $decoded : null; - } - - /** The session creation unix time from meta.json, or null when missing/corrupt. */ - private function createdAt(string $previewId): ?int { - $meta = $this->readMeta($previewId); - if ($meta === null || !isset($meta['createdAt']) || !is_numeric($meta['createdAt'])) { - return null; - } - return (int)$meta['createdAt']; - } - - /** - * Read a revision manifest with its inner maps decoded as associative - * arrays. - * - * @return array{assetRefs:array,fixedRefs:array,documents:array,documentBytes:int}|null - */ - private function readRevision(string $previewId, int $revision): ?array { - $raw = @file_get_contents($this->sessionDir($previewId) . '/revisions/' . $revision . '.json'); - if ($raw === false) { - return null; - } - $decoded = json_decode($raw, true); - if (!is_array($decoded)) { - return null; - } - return [ - 'assetRefs' => $decoded['assetRefs'] ?? [], - 'fixedRefs' => $decoded['fixedRefs'] ?? [], - 'documents' => $decoded['documents'] ?? [], - 'documentBytes' => $decoded['documentBytes'] ?? 0, - ]; - } - - /** - * Drop every revision except $active: delete superseded revision manifests - * and any content-addressed document blob no longer referenced by the active - * revision. Bounds physical document disk to a single revision (the - * ephemeral-preview intent). Assets live in a separate directory and are - * intentionally untouched — they are shared and immutable across revisions. - * Best-effort: a failed unlink is harmless (the next publish or the TTL sweep - * reclaims it) and never fails the publish. - */ - private function pruneSupersededRevisions(string $previewId, int $active): void { - $dir = $this->sessionDir($previewId); - - // Document blob hashes still referenced by the active revision. - $referenced = []; - $revision = $this->readRevision($previewId, $active); - if ($revision !== null) { - foreach ($revision['documents'] as $entry) { - if (isset($entry['hash'])) { - $referenced[$entry['hash']] = true; - } - } - } - - $documentsDir = $dir . '/documents'; - if (is_dir($documentsDir)) { - foreach (scandir($documentsDir) ?: [] as $blob) { - // Only touch content-addressed blobs (sha1 hex); leave any - // in-flight staging temp alone. - if (preg_match('/^[0-9a-f]{40}$/', $blob) === 1 && !isset($referenced[$blob])) { - @unlink($documentsDir . '/' . $blob); - } - } - } - - $revisionsDir = $dir . '/revisions'; - if (is_dir($revisionsDir)) { - foreach (scandir($revisionsDir) ?: [] as $manifest) { - if (preg_match('/^(\d+)\.json$/', $manifest, $m) === 1 && (int)$m[1] !== $active) { - @unlink($revisionsDir . '/' . $manifest); - } - } - } - } - - /** Refresh the idle-TTL / LRU clock for a session. */ - private function touch(string $previewId): void { - $marker = $this->sessionDir($previewId) . '/.accessed'; - $now = ($this->clock)(); - if (!@touch($marker, $now)) { - @file_put_contents($marker, ''); - @touch($marker, $now); - } - clearstatcache(true, $marker); - } - - /** The last-access unix time of a session (0 when unknown). */ - private function lastAccess(string $previewId): int { - $marker = $this->sessionDir($previewId) . '/.accessed'; - clearstatcache(true, $marker); - $mtime = @filemtime($marker); - return $mtime === false ? 0 : $mtime; - } - - /** Logical bytes a session holds: active-revision documents + all assets. */ - private function sessionBytes(string $previewId): int { - return $this->documentBytes($previewId) + $this->assetBytes($previewId); - } - - private function documentBytes(string $previewId): int { - $active = $this->activeRevision($previewId); - if ($active === 0) { - return 0; - } - return $this->readRevision($previewId, $active)['documentBytes'] ?? 0; - } - - private function assetBytes(string $previewId): int { - $total = 0; - $assetDir = $this->sessionDir($previewId) . '/assets'; - if (!is_dir($assetDir)) { - return 0; - } - clearstatcache(); - foreach (scandir($assetDir) ?: [] as $entry) { - // Skip '.', '..', staging temps and any dotfile marker. - if (str_starts_with($entry, '.')) { - continue; - } - $file = $assetDir . '/' . $entry; - if (is_file($file)) { - $total += (int)filesize($file); - } - } - return $total; - } - - /** Sum of logical bytes across every session. */ - private function globalBytes(): int { - $total = 0; - foreach ($this->listSessionIds() as $previewId) { - $total += $this->sessionBytes($previewId); - } - return $total; - } - - /** @return list */ - private function listSessionIds(): array { - $ids = []; - if (!is_dir($this->root)) { - return $ids; - } - foreach (scandir($this->root) ?: [] as $entry) { - if (PreviewPolicy::isValidPreviewId($entry) && is_dir($this->root . '/' . $entry)) { - $ids[] = $entry; - } - } - return $ids; - } - - /** Evict this user's LRU sessions until they are under the per-user cap. */ - private function enforceUserSessionCap(string $ownerUserId): void { - $owned = []; - foreach ($this->listSessionIds() as $previewId) { - if ($this->ownerOf($previewId) === $ownerUserId) { - $owned[$previewId] = $this->lastAccess($previewId); - } - } - while (count($owned) >= $this->limits->maxSessionsPerUser && $owned !== []) { - $lru = array_keys($owned, min($owned))[0]; - $this->removeTree($this->sessionDir($lru)); - unset($owned[$lru]); - } - } - - /** - * Evict OTHER sessions (never $currentId) in LRU order until $incomingBytes - * fits the global budget. Returns the updated global-byte total, or null when - * it cannot fit even after evicting every other session. - */ - private function evictOthersForBudget(string $currentId, int $incomingBytes, int $globalBytes): ?int { - while ($globalBytes + $incomingBytes > $this->limits->globalMaxBytes) { - $candidates = []; - foreach ($this->listSessionIds() as $previewId) { - if ($previewId !== $currentId) { - $candidates[$previewId] = $this->lastAccess($previewId); - } - } - if ($candidates === []) { - return null; - } - $lru = array_keys($candidates, min($candidates))[0]; - $globalBytes -= $this->sessionBytes($lru); - $this->removeTree($this->sessionDir($lru)); - } - return $globalBytes; - } - - /** - * Atomically create $dir/$name with $bytes. Returns true when newly created, - * false when it already existed (immutability preserved — bytes untouched). - */ - private function writeBlobAtomic(string $dir, string $name, string $bytes): bool { - $target = $dir . '/' . $name; - clearstatcache(true, $target); - if (is_file($target)) { - return false; - } - $tmp = $dir . '/.tmp.' . bin2hex(random_bytes(8)); - if (@file_put_contents($tmp, $bytes) === false) { - @unlink($tmp); - return false; - } - // link() is atomic and fails if the target already exists, giving both - // atomic publication and immutability (never overwrite) in one step. - if (@link($tmp, $target)) { - @unlink($tmp); - return true; - } - // Filesystem without hardlink support (or a lost race): publish via - // rename ONLY when the target is still absent, so existing bytes are - // never replaced. - clearstatcache(true, $target); - if (!is_file($target) && @rename($tmp, $target)) { - return true; - } - @unlink($tmp); - return false; - } - - /** Write a small file atomically via temp + rename. */ - private function writeFileAtomic(string $path, string $contents): void { - $tmp = $path . '.tmp.' . bin2hex(random_bytes(8)); - if (@file_put_contents($tmp, $contents) === false) { - @unlink($tmp); - return; - } - if (!@rename($tmp, $path)) { - @unlink($tmp); - } - } - - /** @return resource|null An exclusive advisory lock handle, or null. */ - private function acquireLock(string $dir) { - $this->ensureDir($dir); - $handle = @fopen($dir . '/.lock', 'c'); - if ($handle === false) { - return null; - } - @flock($handle, LOCK_EX); - return $handle; - } - - /** @param resource|null $handle */ - private function releaseLock($handle): void { - if (is_resource($handle)) { - @flock($handle, LOCK_UN); - @fclose($handle); - } - } - - private function ensureDir(string $dir): void { - if (!is_dir($dir)) { - @mkdir($dir, 0770, true); - } - } - - private function removeTree(string $dir): void { - if (!is_dir($dir)) { - @unlink($dir); - return; - } - $items = scandir($dir); - if ($items === false) { - return; - } - foreach ($items as $item) { - if ($item === '.' || $item === '..') { - continue; - } - $path = $dir . '/' . $item; - if (is_dir($path) && !is_link($path)) { - $this->removeTree($path); - } else { - @unlink($path); - } - } - @rmdir($dir); - } - - private function generateUuidV4(): string { - $bytes = random_bytes(16); - $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); - $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); - $hex = bin2hex($bytes); - return sprintf( - '%s-%s-%s-%s-%s', - substr($hex, 0, 8), - substr($hex, 8, 4), - substr($hex, 12, 4), - substr($hex, 16, 4), - substr($hex, 20, 12), - ); - } -} diff --git a/lib/Service/Preview/PreviewSnapshotLimits.php b/lib/Service/Preview/PreviewSnapshotLimits.php new file mode 100644 index 0000000..522b415 --- /dev/null +++ b/lib/Service/Preview/PreviewSnapshotLimits.php @@ -0,0 +1,28 @@ +clock = $clock ?? static fn (): int => time(); + } + + public function limits(): PreviewSnapshotLimits { + return $this->limits; + } + + // ========================================================================= + // Publication + // ========================================================================= + + /** + * Create or atomically replace a snapshot. + * + * $previewId is absent on the first refresh (mint a capability) and present + * afterwards (replace in place). An id that is unknown or owned by somebody + * else is refused, so a capability cannot be claimed from another author. + * + * @return array{previewId:string}|array{error:string,status:int} + */ + public function replace(string $ownerUserId, string $zipPath, ?string $previewId = null): array { + $this->ensureDir($this->root); + $this->sweepExpired(); + + $replacing = $previewId !== null && $previewId !== ''; + if ($replacing) { + $guard = $this->guardExistingCapability((string)$previewId, $ownerUserId); + if ($guard !== null) { + return $guard; + } + } else { + $this->enforceUserSnapshotCap($ownerUserId); + } + $id = $replacing ? (string)$previewId : $this->generateUuidV4(); + + $staging = $this->root . '/.staging-' . bin2hex(random_bytes(12)); + if (!@mkdir($staging . '/content', 0770, true) && !is_dir($staging . '/content')) { + return ['error' => 'Could not create the preview staging directory.', 'status' => 500]; + } + + $extracted = $this->extractInto($zipPath, $staging . '/content'); + if (isset($extracted['error'])) { + $this->removeTree($staging); + return $extracted; + } + + $budget = $this->reserveBudget($id, $extracted['bytes']); + if ($budget !== null) { + $this->removeTree($staging); + return $budget; + } + + $this->writeFileAtomic($staging . '/meta.json', (string)json_encode([ + 'ownerUserId' => $ownerUserId, + 'createdAt' => ($this->clock)(), + 'bytes' => $extracted['bytes'], + ], JSON_UNESCAPED_SLASHES)); + @touch($staging . '/.accessed', ($this->clock)()); + + return $this->publish($staging, $id); + } + + /** + * Refuse a replace whose capability is unknown or owned by somebody else. + * + * @return array{error:string,status:int}|null Null when the replace may proceed. + */ + private function guardExistingCapability(string $previewId, string $ownerUserId): ?array { + if (!PreviewPolicy::isValidPreviewId($previewId)) { + return ['error' => 'Invalid preview capability.', 'status' => 400]; + } + $owner = $this->ownerOf($previewId); + if ($owner === null) { + return ['error' => 'Preview snapshot not found.', 'status' => 404]; + } + if ($owner !== $ownerUserId) { + return ['error' => 'Preview snapshot belongs to another user.', 'status' => 403]; + } + return null; + } + + /** + * Vet and extract the uploaded archive into $contentDir. + * + * @return array{bytes:int}|array{error:string,status:int} + */ + private function extractInto(string $zipPath, string $contentDir): array { + $zip = new ZipArchive(); + if ($zip->open($zipPath) !== true) { + return ['error' => 'Invalid preview archive.', 'status' => 400]; + } + try { + SnapshotArchive::inspect($zip, $this->limits); + SnapshotArchive::extract($zip, $contentDir, $this->limits); + } catch (RuntimeException $e) { + return ['error' => $e->getMessage(), 'status' => 400]; + } finally { + $zip->close(); + } + return ['bytes' => $this->treeBytes($contentDir)]; + } + + /** + * Make room for $incoming bytes in the global budget, evicting other + * snapshots LRU-first. + * + * @return array{error:string,status:int}|null Null when the budget allows it. + */ + private function reserveBudget(string $currentId, int $incoming): ?array { + $global = $this->globalBytes() - $this->snapshotBytes($currentId); + if ($this->evictOthersForBudget($currentId, $incoming, $global) === null) { + return ['error' => 'Preview storage budget exhausted.', 'status' => 507]; + } + return null; + } + + /** + * Swap the staged tree in for $id, keeping the previous one until the new one + * is live so a failure never destroys a working snapshot. + * + * @return array{previewId:string}|array{error:string,status:int} + */ + private function publish(string $staging, string $id): array { + $target = $this->root . '/' . $id; + $backup = $target . '.old-' . bin2hex(random_bytes(6)); + if (is_dir($target) && !@rename($target, $backup)) { + $this->removeTree($staging); + return ['error' => 'Could not replace the preview snapshot.', 'status' => 500]; + } + if (!@rename($staging, $target)) { + if (is_dir($backup)) { + @rename($backup, $target); + } + $this->removeTree($staging); + return ['error' => 'Could not publish the preview snapshot.', 'status' => 500]; + } + if (is_dir($backup)) { + $this->removeTree($backup); + } + return ['previewId' => $id]; + } + + // ========================================================================= + // Serving + // ========================================================================= + + /** + * Resolve a served path inside a snapshot and refresh its idle clock, so a + * preview in use never expires under the author. + * + * @return array{kind:string,contentType:string,isScriptable:bool,bytes?:string,filePath?:string,size?:int,etag?:string}|null + */ + public function resolve(string $previewId, string $rawPath): ?array { + if (!PreviewPolicy::isValidPreviewId($previewId) || $this->isExpired($previewId)) { + return null; + } + $path = PreviewPolicy::normalizePath($rawPath); + if ($path === null) { + return null; + } + $file = $this->resolveInsideContent($previewId, $path); + if ($file === null) { + return null; + } + $this->touch($previewId); + + $contentType = PreviewPolicy::mimeForPath($path); + $isScriptable = PreviewPolicy::isScriptable($contentType); + if ($isScriptable) { + // Scriptable documents are rewritten on every refresh, so they are + // served whole and uncached rather than revalidated. + $bytes = @file_get_contents($file); + if ($bytes === false) { + return null; + } + return [ + 'kind' => 'document', + 'contentType' => $contentType, + 'isScriptable' => true, + 'bytes' => $bytes, + ]; + } + $size = (int)@filesize($file); + return [ + 'kind' => 'asset', + 'contentType' => $contentType, + 'isScriptable' => false, + 'filePath' => $file, + 'size' => $size, + 'etag' => sha1($path . '|' . (string)@filemtime($file) . '|' . $size), + ]; + } + + /** + * Map a normalized relative path onto a real file inside the snapshot's + * content directory. + * + * `normalizePath()` has already rejected traversal, but the tree is author + * controlled, so the resolved path is confirmed with `realpath()` as well: a + * symlink that survived extraction could otherwise point outside. + */ + private function resolveInsideContent(string $previewId, string $path): ?string { + $contentDir = @realpath($this->snapshotDir($previewId) . '/content'); + if ($contentDir === false) { + return null; + } + $file = @realpath($contentDir . '/' . $path); + if ($file === false || !is_file($file) || !str_starts_with($file, $contentDir . '/')) { + return null; + } + return $file; + } + + // ========================================================================= + // Lifecycle + // ========================================================================= + + /** Whether the snapshot exists on disk (independent of TTL). */ + public function exists(string $previewId): bool { + if (!PreviewPolicy::isValidPreviewId($previewId)) { + return false; + } + $meta = $this->snapshotDir($previewId) . '/meta.json'; + clearstatcache(true, $meta); + return is_file($meta); + } + + /** The owner user id of a snapshot, or null when it does not exist. */ + public function ownerOf(string $previewId): ?string { + $meta = $this->readMeta($previewId); + $owner = $meta['ownerUserId'] ?? null; + return is_string($owner) ? $owner : null; + } + + /** Delete a snapshot owned by $ownerUserId. Returns whether it was removed. */ + public function delete(string $previewId, string $ownerUserId): bool { + if (!$this->exists($previewId) || $this->ownerOf($previewId) !== $ownerUserId) { + return false; + } + $this->removeTree($this->snapshotDir($previewId)); + return true; + } + + /** Whether a snapshot has been idle longer than the TTL. */ + public function isExpired(string $previewId): bool { + $marker = $this->snapshotDir($previewId) . '/.accessed'; + clearstatcache(true, $marker); + $accessed = @filemtime($marker); + if ($accessed !== false) { + return (($this->clock)() - $accessed) > $this->limits->ttlSeconds; + } + // A missing/unreadable `.accessed` marker must NOT make a snapshot + // immortal — that would keep it counting against the global byte budget + // forever and never let sweepExpired reclaim it. Fall back to meta.json + // createdAt as the age clock; if meta.json is missing/corrupt too, the + // directory is unusable (it can neither be owned nor served), so treat it + // as expired and let sweepExpired reclaim it. + $meta = $this->readMeta($previewId); + $createdAt = isset($meta['createdAt']) ? (int)$meta['createdAt'] : null; + if ($createdAt === null) { + return true; + } + return (($this->clock)() - $createdAt) > $this->limits->ttlSeconds; + } + + /** + * Remove every snapshot idle longer than the TTL. Returns the count swept. + * Called by the background cleanup job and opportunistically on publish. + */ + public function sweepExpired(): int { + $swept = 0; + foreach ($this->listSnapshotIds() as $previewId) { + if ($this->isExpired($previewId)) { + $this->removeTree($this->snapshotDir($previewId)); + $swept++; + } + } + return $swept; + } + + // ========================================================================= + // Budgets + // ========================================================================= + + /** Evict this user's LRU snapshots until they are under the per-user cap. */ + private function enforceUserSnapshotCap(string $ownerUserId): void { + $owned = []; + foreach ($this->listSnapshotIds() as $previewId) { + if ($this->ownerOf($previewId) === $ownerUserId) { + $owned[$previewId] = $this->lastAccess($previewId); + } + } + while (count($owned) >= $this->limits->maxSnapshotsPerUser && $owned !== []) { + $lru = array_keys($owned, min($owned))[0]; + $this->removeTree($this->snapshotDir($lru)); + unset($owned[$lru]); + } + } + + /** + * Evict OTHER snapshots (never $currentId) in LRU order until $incoming fits + * the global budget. Returns the updated total, or null when it cannot fit + * even after evicting every other snapshot. + */ + private function evictOthersForBudget(string $currentId, int $incoming, int $globalBytes): ?int { + while ($globalBytes + $incoming > $this->limits->globalMaxBytes) { + $candidates = []; + foreach ($this->listSnapshotIds() as $previewId) { + if ($previewId !== $currentId) { + $candidates[$previewId] = $this->lastAccess($previewId); + } + } + if ($candidates === []) { + return null; + } + $lru = array_keys($candidates, min($candidates))[0]; + $globalBytes -= $this->snapshotBytes($lru); + $this->removeTree($this->snapshotDir($lru)); + } + return $globalBytes; + } + + /** Sum of logical bytes across every snapshot. */ + private function globalBytes(): int { + $total = 0; + foreach ($this->listSnapshotIds() as $previewId) { + $total += $this->snapshotBytes($previewId); + } + return $total; + } + + /** + * The byte total a snapshot holds, recorded at publish time. + * + * Read from meta.json rather than walked: the extraction already counted + * every byte it wrote, so re-walking the tree on each budget check would be + * the same answer at a much higher cost. + */ + private function snapshotBytes(string $previewId): int { + $meta = $this->readMeta($previewId); + return isset($meta['bytes']) ? (int)$meta['bytes'] : 0; + } + + /** Total size of a freshly extracted tree. */ + private function treeBytes(string $dir): int { + $total = 0; + foreach (scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $path = $dir . '/' . $entry; + $total += is_dir($path) ? $this->treeBytes($path) : (int)@filesize($path); + } + return $total; + } + + // ========================================================================= + // Helpers + // ========================================================================= + + private function snapshotDir(string $previewId): string { + return $this->root . '/' . $previewId; + } + + /** @return array|null */ + private function readMeta(string $previewId): ?array { + if (!PreviewPolicy::isValidPreviewId($previewId)) { + return null; + } + $raw = @file_get_contents($this->snapshotDir($previewId) . '/meta.json'); + if ($raw === false) { + return null; + } + $decoded = json_decode($raw, true); + return is_array($decoded) ? $decoded : null; + } + + /** Refresh the idle-TTL / LRU clock. */ + private function touch(string $previewId): void { + $marker = $this->snapshotDir($previewId) . '/.accessed'; + $now = ($this->clock)(); + if (!@touch($marker, $now)) { + @file_put_contents($marker, ''); + @touch($marker, $now); + } + clearstatcache(true, $marker); + } + + /** The last-access unix time of a snapshot (0 when unknown). */ + private function lastAccess(string $previewId): int { + $marker = $this->snapshotDir($previewId) . '/.accessed'; + clearstatcache(true, $marker); + $mtime = @filemtime($marker); + return $mtime === false ? 0 : $mtime; + } + + /** @return list */ + private function listSnapshotIds(): array { + $ids = []; + if (!is_dir($this->root)) { + return $ids; + } + foreach (scandir($this->root) ?: [] as $entry) { + if (PreviewPolicy::isValidPreviewId($entry) && is_dir($this->root . '/' . $entry)) { + $ids[] = $entry; + } + } + return $ids; + } + + /** Write a small file atomically via temp + rename. */ + private function writeFileAtomic(string $path, string $contents): void { + $tmp = $path . '.tmp.' . bin2hex(random_bytes(8)); + if (@file_put_contents($tmp, $contents) === false) { + @unlink($tmp); + return; + } + if (!@rename($tmp, $path)) { + @unlink($tmp); + } + } + + private function ensureDir(string $dir): void { + if (!is_dir($dir)) { + @mkdir($dir, 0770, true); + } + } + + private function removeTree(string $dir): void { + if (!is_dir($dir)) { + @unlink($dir); + return; + } + $items = scandir($dir); + if ($items === false) { + return; + } + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $path = $dir . '/' . $item; + if (is_dir($path) && !is_link($path)) { + $this->removeTree($path); + } else { + @unlink($path); + } + } + @rmdir($dir); + } + + private function generateUuidV4(): string { + $bytes = random_bytes(16); + $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); + $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); + $hex = bin2hex($bytes); + return sprintf( + '%s-%s-%s-%s-%s', + substr($hex, 0, 8), + substr($hex, 8, 4), + substr($hex, 12, 4), + substr($hex, 16, 4), + substr($hex, 20, 12), + ); + } +} diff --git a/lib/Service/Preview/SnapshotArchive.php b/lib/Service/Preview/SnapshotArchive.php new file mode 100644 index 0000000..07c8f5e --- /dev/null +++ b/lib/Service/Preview/SnapshotArchive.php @@ -0,0 +1,177 @@ +numFiles > $limits->maxFilesPerSnapshot) { + throw new RuntimeException('Preview archive contains too many files.'); + } + $declared = 0; + $hasIndex = false; + for ($index = 0; $index < $zip->numFiles; $index++) { + $entry = self::inspectEntry($zip, $index); + if ($entry === null) { + continue; // directory + } + $declared += $entry['size']; + if ($declared > $limits->maxBytesPerSnapshot) { + throw new RuntimeException('Preview archive is too large.'); + } + $hasIndex = $hasIndex || $entry['name'] === 'index.html'; + } + if (!$hasIndex) { + throw new RuntimeException('Preview archive must contain index.html.'); + } + } + + /** + * Extract a vetted archive into $targetDir, enforcing the byte budget on the + * bytes actually written. + * + * Entries are streamed rather than handed to `extractTo()` so the budget can + * be enforced mid-file: an entry that inflates past the cap is abandoned + * as soon as the cap is crossed, not after it has filled the disk. + * + * @throws RuntimeException When extraction fails or exceeds the budget. + */ + public static function extract(ZipArchive $zip, string $targetDir, PreviewSnapshotLimits $limits): void { + $written = 0; + for ($index = 0; $index < $zip->numFiles; $index++) { + $name = (string)$zip->getNameIndex($index); + if (str_ends_with($name, '/')) { + continue; + } + $destination = $targetDir . '/' . $name; + $parent = dirname($destination); + if (!is_dir($parent) && !@mkdir($parent, 0700, true) && !is_dir($parent)) { + throw new RuntimeException('Could not create a preview directory.'); + } + $written += self::extractEntry($zip, $index, $destination, $limits->maxBytesPerSnapshot - $written); + } + } + + /** + * Stream one entry to disk, stopping if it would exceed $remaining bytes. + * + * @return int Bytes written for this entry. + * @throws RuntimeException + */ + private static function extractEntry(ZipArchive $zip, int $index, string $destination, int $remaining): int { + $source = $zip->getStream((string)$zip->getNameIndex($index)); + if (!is_resource($source)) { + throw new RuntimeException('Could not read the preview archive.'); + } + $target = @fopen($destination, 'wb'); + if (!is_resource($target)) { + fclose($source); + throw new RuntimeException('Could not write the preview snapshot.'); + } + $written = 0; + try { + while (!feof($source)) { + $chunk = fread($source, 131072); + if ($chunk === false) { + throw new RuntimeException('Could not read the preview archive.'); + } + $written += strlen($chunk); + if ($written > $remaining) { + throw new RuntimeException('Preview archive is too large.'); + } + if ($chunk !== '' && fwrite($target, $chunk) === false) { + throw new RuntimeException('Could not write the preview snapshot.'); + } + } + } finally { + fclose($source); + fclose($target); + } + return $written; + } + + /** + * Vet a single entry. + * + * @return array{name:string,size:int}|null Null for a directory. + * @throws RuntimeException When the entry is unsafe. + */ + private static function inspectEntry(ZipArchive $zip, int $index): ?array { + $name = $zip->getNameIndex($index); + $stat = $zip->statIndex($index); + $isDirectory = is_string($name) && str_ends_with($name, '/'); + $validated = $isDirectory ? rtrim((string)$name, '/') : (string)$name; + if (!is_string($name) || !is_array($stat) || !self::isSafePath($validated)) { + throw new RuntimeException('Unsafe path in the preview archive.'); + } + if ($isDirectory) { + return null; + } + if (self::isSymlink($zip, $index)) { + throw new RuntimeException('The preview archive contains a symbolic link.'); + } + return ['name' => $name, 'size' => (int)($stat['size'] ?? 0)]; + } + + /** + * Whether an entry is a Unix symbolic link. + * + * A link is stored as a tiny entry whose contents are the target path, so it + * passes every size and path check while pointing anywhere on the filesystem. + */ + private static function isSymlink(ZipArchive $zip, int $index): bool { + $opsys = 0; + $attributes = 0; + return $zip->getExternalAttributesIndex($index, $opsys, $attributes) + && $opsys === ZipArchive::OPSYS_UNIX + && (($attributes >> 16) & 0xf000) === 0xa000; + } + + /** + * Whether a relative path is canonical and safe. + * + * There is deliberately no reserved-name list: the store keeps its own + * metadata OUTSIDE the extracted tree, so no author path can collide with it. + */ + private static function isSafePath(string $path): bool { + if ($path === '' || str_contains($path, "\0") || str_contains($path, '\\') + || $path[0] === '/') { + return false; + } + foreach (explode('/', $path) as $part) { + if ($part === '' || $part === '.' || $part === '..') { + return false; + } + } + return true; + } +} diff --git a/src/embed/exe_media_host.js b/src/embed/exe_media_host.js deleted file mode 100644 index b80265f..0000000 --- a/src/embed/exe_media_host.js +++ /dev/null @@ -1,533 +0,0 @@ -/** - * exe-media-host — reference PARENT-side relay for the eXeLearning external-media bridge. - * - * Host plugins (Moodle, WordPress, Procomún, …) include this one file on the trusted page - * that embeds an eXeLearning package inside an opaque-origin sandboxed iframe. It lets the - * package's YouTube/Vimeo videos play (which they cannot do inside the opaque frame) by: - * - * - completing the capability handshake announced by the child runtime (window identity - * + per-view nonce + a transferred MessageChannel port), - * - opening an accessible with the real provider player on the trusted side, and - * - relaying a tiny, validated set of media commands/events over the private port. - * - * Security: the relay NEVER trusts `event.origin` (it is the string "null"). The only - * window-level message accepted is the child's `welcome`-triggering `hello`, gated by - * `event.source === iframe.contentWindow`. Steady-state media traffic flows only over the - * private port. The child sends just `{provider, videoId}`; the relay reconstructs the - * canonical URL from a fixed per-provider template (never a child-supplied URL). - * - * Classic browser script (no ES module syntax); depends on the shared `exeMediaPolicy`. - * - * mod_exelearning VARIANT (DEC-0067): vendored from eXeLearning's exe-media-host.js, but the - * provider adapters drive the player by RAW postMessage (YouTube enablejsapi=1, Vimeo api=1) - * instead of the YouTube IFrame Player API / Vimeo Player SDK, so the trusted Moodle page - * never loads third-party player script (erseco). The handshake, accessible modal and the - * command/event relay are otherwise unchanged. The child runtime (exe_media_bridge.js + - * exe_media_policy.js, baked into the package by eXeLearning) is unmodified; the interactive - * video iDevice already speaks this contract (window.exeMediaBridge.openMedia → controller). - * Canonical upstream: exelearning/public/app/common/exe_media_bridge/. - * - * @license AGPL-3.0 - */ -(function (root) { - 'use strict'; - - var policy = root.exeMediaPolicy; - var TYPE = policy.TYPE; - var VERSION = policy.VERSION; - - function tr(key) { - return typeof root._ === 'function' ? root._(key) : key; - } - - function isThenable(v) { - return v && typeof v.then === 'function'; - } - - function genNonce(opts) { - if (opts && typeof opts.genId === 'function') return opts.genId(); - if (root.crypto && typeof root.crypto.randomUUID === 'function') return root.crypto.randomUUID(); - return 'n-' + Math.random().toString(36).slice(2); - } - - function makeChannel(opts) { - if (opts && typeof opts.channelFactory === 'function') return opts.channelFactory(); - return new MessageChannel(); - } - - function getInterval(opts) { - return { - set: (opts && opts.setInterval) || (root.setInterval ? root.setInterval.bind(root) : function () {}), - clear: (opts && opts.clearInterval) || (root.clearInterval ? root.clearInterval.bind(root) : function () {}), - }; - } - - // ---- Raw-postMessage provider adapters (NO YouTube IFrame API / Vimeo SDK) ---------- - // mod_exelearning controls the promoted player by posting the providers' documented - // postMessage commands straight to the player iframe and parsing its event messages, so - // the trusted Moodle page never loads third-party player SDK script (erseco, DEC-0067). - // Both providers use JSON-STRING messages. The host polls getCurrentTime()/getDuration() - // every 250ms to emit timeupdate; these adapters keep those values fresh from the - // player's pushed infoDelivery (YouTube) / timeupdate (Vimeo) events. Adapters are still - // injectable (opts.youtubeFactory / opts.vimeoFactory) for tests. - - function num(v) { - return typeof v === 'number' && isFinite(v) ? v : undefined; - } - - function parentOrigin() { - try { - return (root.location && root.location.origin) || ''; - } catch (e) { - return ''; - } - } - - // Pure command builders (exposed for unit tests). Closed action set; null otherwise. - function ytCommand(action, value) { - switch (action) { - case 'play': return { event: 'command', func: 'playVideo', args: [] }; - case 'pause': return { event: 'command', func: 'pauseVideo', args: [] }; - case 'seek': return { event: 'command', func: 'seekTo', args: [Number(value) || 0, true] }; - case 'listen': return { event: 'listening' }; - default: return null; - } - } - function vimeoCommand(action, value) { - switch (action) { - case 'play': return { method: 'play' }; - case 'pause': return { method: 'pause' }; - case 'seek': return { method: 'setCurrentTime', value: Number(value) || 0 }; - default: return null; - } - } - - // Pure inbound-event parsers (exposed for unit tests). Both providers post JSON strings. - function parseRaw(raw) { - if (typeof raw === 'string') { - try { return JSON.parse(raw); } catch (e) { return null; } - } - return raw && typeof raw === 'object' ? raw : null; - } - function parseYtEvent(raw) { - var d = parseRaw(raw); - if (!d || typeof d.event !== 'string') return null; - if (d.event === 'onReady') return { kind: 'ready' }; - if (d.event === 'onError') return { kind: 'error', code: String(d.info != null ? d.info : '') }; - if (d.event === 'infoDelivery' && d.info && typeof d.info === 'object') { - return { kind: 'info', currentTime: num(d.info.currentTime), duration: num(d.info.duration), playerState: num(d.info.playerState) }; - } - if (d.event === 'onStateChange') { - return { kind: 'state', playerState: num(typeof d.info === 'object' && d.info ? d.info.playerState : d.info) }; - } - return null; - } - function parseVimeoEvent(raw) { - var d = parseRaw(raw); - if (!d || typeof d.event !== 'string') return null; - if (d.event === 'ready') return { kind: 'ready' }; - if (d.event === 'timeupdate' && d.data) return { kind: 'timeupdate', currentTime: num(d.data.seconds), duration: num(d.data.duration) }; - if (d.event === 'play') return { kind: 'play' }; - if (d.event === 'pause') return { kind: 'pause' }; - if (d.event === 'ended' || d.event === 'finish') return { kind: 'ended' }; - if (d.event === 'error') return { kind: 'error', code: 'vimeo_error' }; - return null; - } - - function youtubeRawAdapter(container, videoId, cb) { - var doc = container.ownerDocument || root.document; - var origin = parentOrigin(); - var target = 'https://www.youtube-nocookie.com'; - var src = target + '/embed/' + videoId + '?enablejsapi=1&rel=0&modestbranding=1' + - (origin ? '&origin=' + encodeURIComponent(origin) : '') + - (cb.start ? '&start=' + Math.floor(cb.start) : '') + - (cb.autoplay ? '&autoplay=1' : ''); - var frame = doc.createElement('iframe'); - frame.setAttribute('src', src); - frame.setAttribute('allow', 'autoplay; encrypted-media; fullscreen; picture-in-picture'); - frame.setAttribute('allowfullscreen', ''); - frame.setAttribute('referrerpolicy', 'strict-origin-when-cross-origin'); - frame.style.cssText = 'border:0;width:100%;height:100%;'; - var cache = { currentTime: 0, duration: 0 }; - - function post(msg) { - if (!msg) return; - try { frame.contentWindow.postMessage(JSON.stringify(msg), target); } catch (e) { /* not ready */ } - } - function onMessage(e) { - if (e.source !== frame.contentWindow) return; - var ev = parseYtEvent(e.data); - if (!ev) return; - if (ev.kind === 'ready') { - if (cb.onReady) cb.onReady(cache.duration || undefined); - } else if (ev.kind === 'info') { - if (typeof ev.currentTime === 'number') cache.currentTime = ev.currentTime; - if (typeof ev.duration === 'number') cache.duration = ev.duration; - signalState(ev.playerState); - } else if (ev.kind === 'state') { - signalState(ev.playerState); - } else if (ev.kind === 'error' && cb.onError) { - cb.onError(ev.code); - } - } - function signalState(s) { - if (s === 1 && cb.onPlay) cb.onPlay(); - else if (s === 2 && cb.onPause) cb.onPause(); - else if (s === 0 && cb.onEnded) cb.onEnded(); - } - if (root.addEventListener) root.addEventListener('message', onMessage); - frame.addEventListener('load', function () { post(ytCommand('listen')); }); - container.appendChild(frame); - - return { - play: function () { post(ytCommand('play')); }, - pause: function () { post(ytCommand('pause')); }, - seek: function (t) { post(ytCommand('seek', t)); }, - getCurrentTime: function () { return cache.currentTime; }, - getDuration: function () { return cache.duration; }, - destroy: function () { - if (root.removeEventListener) root.removeEventListener('message', onMessage); - if (frame.parentNode) frame.parentNode.removeChild(frame); - }, - }; - } - - function vimeoRawAdapter(container, videoId, cb) { - var doc = container.ownerDocument || root.document; - var target = 'https://player.vimeo.com'; - var pid = 'exe-vimeo-' + videoId; - var src = target + '/video/' + videoId + '?api=1&player_id=' + encodeURIComponent(pid) + - (cb.autoplay ? '&autoplay=1' : ''); - var frame = doc.createElement('iframe'); - frame.setAttribute('src', src); - frame.setAttribute('id', pid); - frame.setAttribute('allow', 'autoplay; fullscreen; picture-in-picture'); - frame.setAttribute('allowfullscreen', ''); - frame.setAttribute('referrerpolicy', 'strict-origin-when-cross-origin'); - frame.style.cssText = 'border:0;width:100%;height:100%;'; - var cache = { currentTime: 0, duration: 0 }; - - function post(msg) { - if (!msg) return; - try { frame.contentWindow.postMessage(JSON.stringify(msg), target); } catch (e) { /* not ready */ } - } - function subscribe() { - ['timeupdate', 'play', 'pause', 'ended', 'error'].forEach(function (name) { - post({ method: 'addEventListener', value: name }); - }); - if (cb.start) post(vimeoCommand('seek', cb.start)); - } - function onMessage(e) { - if (e.source !== frame.contentWindow) return; - var ev = parseVimeoEvent(e.data); - if (!ev) return; - if (ev.kind === 'ready') { - subscribe(); - if (cb.onReady) cb.onReady(cache.duration || undefined); - } else if (ev.kind === 'timeupdate') { - if (typeof ev.currentTime === 'number') cache.currentTime = ev.currentTime; - if (typeof ev.duration === 'number') cache.duration = ev.duration; - } else if (ev.kind === 'play' && cb.onPlay) { - cb.onPlay(); - } else if (ev.kind === 'pause' && cb.onPause) { - cb.onPause(); - } else if (ev.kind === 'ended' && cb.onEnded) { - cb.onEnded(); - } else if (ev.kind === 'error' && cb.onError) { - cb.onError(ev.code); - } - } - if (root.addEventListener) root.addEventListener('message', onMessage); - container.appendChild(frame); - - return { - play: function () { post(vimeoCommand('play')); }, - pause: function () { post(vimeoCommand('pause')); }, - seek: function (t) { post(vimeoCommand('seek', t)); }, - getCurrentTime: function () { return cache.currentTime; }, - getDuration: function () { return cache.duration; }, - destroy: function () { - if (root.removeEventListener) root.removeEventListener('message', onMessage); - if (frame.parentNode) frame.parentNode.removeChild(frame); - }, - }; - } - - // ---- Accessible modal ------------------------------------------------------------- - - function buildModal(session) { - var opts = session.opts || {}; - var doc = opts.document || root.document; - var dialog = doc.createElement('dialog'); - dialog.className = 'exe-media-modal'; - dialog.setAttribute('aria-label', tr('Video player')); - try { - dialog.setAttribute('closedby', 'any'); // light-dismiss where supported - } catch (e) { - /* older engines */ - } - - var closeBtn = doc.createElement('button'); - closeBtn.setAttribute('type', 'button'); - closeBtn.className = 'exe-media-modal__close'; - closeBtn.setAttribute('aria-label', tr('Close video')); - closeBtn.textContent = tr('Close'); - closeBtn.addEventListener('click', function () { - requestClose(session); - }); - - var body = doc.createElement('div'); - body.className = 'exe-media-modal__body'; - - dialog.appendChild(closeBtn); - dialog.appendChild(body); - - // Safari light-dismiss fallback: a click whose target is the dialog itself is the backdrop. - dialog.addEventListener('click', function (e) { - if (e.target === dialog) requestClose(session); - }); - // Esc / platform close request. - dialog.addEventListener('close', function () { - if (session.hiding) { - session.hiding = false; - return; - } - requestClose(session); - }); - - (doc.body || doc.documentElement).appendChild(dialog); - if (typeof dialog.showModal === 'function') dialog.showModal(); - - session.dialog = dialog; - return body; - } - - function showModal(session) { - if (session.dialog && typeof session.dialog.showModal === 'function') session.dialog.showModal(); - } - - function hideModal(session) { - if (session.dialog && typeof session.dialog.close === 'function') { - session.hiding = true; - session.dialog.close(); - } - } - - function requestClose(session) { - if (session.closed) return; - session.closed = true; - sendEvent(session, { action: 'closed' }); - destroyAdapter(session); - if (session.dialog) { - if (typeof session.dialog.close === 'function') session.dialog.close(); - if (typeof session.dialog.remove === 'function') session.dialog.remove(); - } - } - - function destroyAdapter(session) { - if (session.pollTimer != null) { - session.interval.clear(session.pollTimer); - session.pollTimer = null; - } - if (session.adapter && session.adapter.destroy) session.adapter.destroy(); - session.adapter = null; - } - - // ---- Media event relay ------------------------------------------------------------ - - function sendEvent(session, ev) { - ev.type = TYPE; - ev.v = VERSION; - if (session.port1) session.port1.postMessage(ev); - } - - function emitTimeupdate(session, ct, dur) { - if (typeof ct === 'number' && isFinite(ct) && typeof dur === 'number' && isFinite(dur)) { - sendEvent(session, { action: 'timeupdate', currentTime: ct, duration: dur }); - } - } - - function startPolling(session) { - if (session.pollTimer != null) return; - session.pollTimer = session.interval.set(function () { - if (!session.adapter) return; - var ct = session.adapter.getCurrentTime(); - var dur = session.adapter.getDuration(); - if (isThenable(ct) || isThenable(dur)) { - Promise.all([Promise.resolve(ct), Promise.resolve(dur)]).then(function (r) { - emitTimeupdate(session, r[0], r[1]); - }); - } else { - emitTimeupdate(session, ct, dur); - } - }, 250); - } - - function openMedia(session, provider, videoId, openOpts) { - var opts = session.opts || {}; - // Single active media per session: discard any previous player/poll-timer/ - // before opening a new one, so repeated 'open' commands cannot stack modals or orphan - // provider players (host-page resource exhaustion). Remove the old dialog directly - // (no .close()) so its 'close' listener does not fire a spurious 'closed' to the child. - destroyAdapter(session); - if (session.dialog) { - if (typeof session.dialog.remove === 'function') session.dialog.remove(); - session.dialog = null; - } - var container = buildModal(session); - var callbacks = { - start: openOpts.start, - autoplay: openOpts.autoplay, - onReady: function (duration) { - sendEvent(session, { action: 'ready', duration: duration }); - startPolling(session); - }, - onPlay: function () { sendEvent(session, { action: 'play' }); }, - onPause: function () { sendEvent(session, { action: 'pause' }); }, - onEnded: function () { sendEvent(session, { action: 'ended' }); }, - onSeeked: function (t) { sendEvent(session, { action: 'seeked', currentTime: t }); }, - onError: function (code) { sendEvent(session, { action: 'error', code: String(code), fatal: true }); }, - }; - var factory = - provider === 'vimeo' - ? opts.vimeoFactory || vimeoRawAdapter - : opts.youtubeFactory || youtubeRawAdapter; - session.adapter = factory(container, videoId, callbacks); - } - - function answerState(session, reqId, field) { - if (!session.adapter) return; - var value = field === 'duration' ? session.adapter.getDuration() : session.adapter.getCurrentTime(); - Promise.resolve(value).then(function (v) { - var ev = { action: 'state', reqId: reqId }; - ev[field === 'duration' ? 'duration' : 'currentTime'] = v; - sendEvent(session, ev); - }); - } - - function processCommand(session, data) { - if (!data || data.type !== TYPE || data.v !== VERSION) return; - if (data.exelearningBridge !== session.nonce) return; // nonce gate - if (!policy.COMMANDS[data.action]) return; - - if (data.action === 'open') { - var url = policy.canonicalEmbedUrl(data.provider, data.videoId); - if (!url) { - sendEvent(session, { action: 'error', code: 'unsupported_provider', fatal: true }); - return; - } - session.closed = false; - openMedia(session, data.provider, data.videoId, { start: data.start, autoplay: data.autoplay }); - return; - } - if (!policy.validateCommand(data, session.nonce)) return; - - var a = session.adapter; - switch (data.action) { - case 'play': if (a) a.play(); break; - case 'pause': if (a) a.pause(); break; - case 'seek': if (a) a.seek(data.t); break; - case 'getCurrentTime': answerState(session, data.reqId, 'currentTime'); break; - case 'getDuration': answerState(session, data.reqId, 'duration'); break; - case 'hide': hideModal(session); break; - case 'show': showModal(session); break; - case 'close': requestClose(session); break; - default: break; - } - } - - // ---- Handshake + attachment ------------------------------------------------------- - - var records = []; - - function teardown(session) { - if (!session) return; - destroyAdapter(session); - if (session.port1 && session.port1.close) session.port1.close(); - if (session.dialog && session.dialog.remove) session.dialog.remove(); - } - - function handleHello(rec, data) { - var existing = rec.session; - if (existing && existing.helloId === data.helloId) return; // duplicate - if (existing) teardown(existing); // new helloId → re-pair - - var nonce = genNonce(rec.opts); - var channel = makeChannel(rec.opts); - var session = { - iframe: rec.iframe, - opts: rec.opts, - nonce: nonce, - helloId: data.helloId, - port1: channel.port1, - adapter: null, - dialog: null, - pollTimer: null, - hiding: false, - closed: false, - interval: getInterval(rec.opts), - }; - rec.session = session; - - channel.port1.onmessage = function (e) { - processCommand(session, e.data); - }; - if (typeof channel.port1.start === 'function') channel.port1.start(); - - rec.iframe.contentWindow.postMessage( - { type: TYPE, v: VERSION, action: 'welcome', helloId: data.helloId, exelearningBridge: nonce }, - '*', - [channel.port2], - ); - } - - function onWindowMessage(rec, e) { - if (!rec.iframe || !rec.iframe.contentWindow || e.source !== rec.iframe.contentWindow) return; - if (policy.isHello(e.data)) handleHello(rec, e.data); - } - - /** - * Register an iframe that renders eXeLearning content and start relaying its media. - * @returns {{detach: function}} handle - */ - function attach(iframe, opts) { - opts = opts || {}; - var win = opts.win || root; - var rec = { iframe: iframe, opts: opts, session: null, win: win, handler: null }; - rec.handler = function (e) { - onWindowMessage(rec, e); - }; - if (win.addEventListener) win.addEventListener('message', rec.handler); - records.push(rec); - return { - detach: function () { - if (win.removeEventListener) win.removeEventListener('message', rec.handler); - teardown(rec.session); - var i = records.indexOf(rec); - if (i >= 0) records.splice(i, 1); - }, - }; - } - - function resetForTests() { - for (var i = 0; i < records.length; i++) { - var rec = records[i]; - if (rec.win && rec.win.removeEventListener) rec.win.removeEventListener('message', rec.handler); - teardown(rec.session); - } - records = []; - } - - root.exeMediaHost = { - attach: attach, - buildModal: buildModal, - _youtubeAdapter: youtubeRawAdapter, - _vimeoAdapter: vimeoRawAdapter, - _ytCommand: ytCommand, - _vimeoCommand: vimeoCommand, - _parseYtEvent: parseYtEvent, - _parseVimeoEvent: parseVimeoEvent, - _processCommand: processCommand, - _resetForTests: resetForTests, - }; -})(typeof window !== 'undefined' ? window : globalThis); diff --git a/src/embed/exe_media_policy.js b/src/embed/exe_media_policy.js deleted file mode 100644 index 34cf942..0000000 --- a/src/embed/exe_media_policy.js +++ /dev/null @@ -1,258 +0,0 @@ -/** - * exe_media_policy — pure, framework-free policy for the external-media bridge. - * - * Single source of truth shared by: - * - the child runtime (exe_media_bridge.js) that runs inside exported content, and - * - the reference parent relay (exe-media-host.js) that runs in the trusted host page. - * - * Responsibilities (all pure — no DOM mutation, no postMessage): - * - Detect and normalize supported external media (YouTube, Vimeo) into a neutral - * descriptor; recognize PDFs for a deferred open-in-new-tab fallback. - * - Rebuild canonical, privacy-friendly embed URLs from a bare provider id, so the - * bridge never has to trust an attacker-supplied URL (kills redirect-laundering). - * - Validate the versioned postMessage/MessageChannel contract (handshake, commands, - * events) with a strict schema, a closed action enum and a provider allowlist. - * - * This file is a CLASSIC browser script (no ES module syntax) so it can be shipped - * verbatim inside exported packages and loaded over file:// — while still being - * importable (for its side effect) by the Vitest suite, which reads the global it sets. - * - * @license AGPL-3.0 - */ -(function (root) { - 'use strict'; - - var TYPE = 'exe-media'; - var VERSION = 1; - - // Bare-id shapes. YouTube ids are exactly 11 url-safe chars; Vimeo ids are numeric. - var ID_RE = { - youtube: /^[A-Za-z0-9_-]{11}$/, - vimeo: /^[0-9]{6,12}$/, - }; - - // Host allow-lists per provider. Anything else never matches a provider, so IPs, - // loopback, *.local and look-alike domains (evil.com/watch?v=...) cannot slip in. - var YOUTUBE_HOSTS = { - 'youtube.com': 1, - 'www.youtube.com': 1, - 'm.youtube.com': 1, - 'music.youtube.com': 1, - 'youtube-nocookie.com': 1, - 'www.youtube-nocookie.com': 1, - }; - var YOUTU_BE_HOSTS = { 'youtu.be': 1, 'www.youtu.be': 1 }; - var VIMEO_HOSTS = { 'vimeo.com': 1, 'www.vimeo.com': 1 }; - var VIMEO_PLAYER_HOSTS = { 'player.vimeo.com': 1 }; - - // Closed action enums. Anything outside these is dropped before any work happens. - var COMMANDS = { open: 1, play: 1, pause: 1, seek: 1, getCurrentTime: 1, getDuration: 1, hide: 1, show: 1, close: 1 }; - var EVENTS = { ready: 1, play: 1, pause: 1, ended: 1, timeupdate: 1, seeked: 1, state: 1, error: 1, closed: 1 }; - - function isObject(v) { - return v !== null && typeof v === 'object'; - } - - function finiteNonNeg(n) { - return typeof n === 'number' && isFinite(n) && n >= 0; - } - - function isAllowedProvider(provider) { - return provider === 'youtube' || provider === 'vimeo'; - } - - function isValidVideoId(provider, id) { - var re = ID_RE[provider]; - return !!re && typeof id === 'string' && re.test(id); - } - - /** - * Build the canonical, privacy-friendly embed URL for a provider id. Returns null - * for unknown providers or ids that fail the provider's shape — so a malicious id - * can never be templated into a live URL. - */ - function canonicalEmbedUrl(provider, id) { - if (provider === 'youtube' && isValidVideoId('youtube', id)) { - return 'https://www.youtube-nocookie.com/embed/' + id; - } - if (provider === 'vimeo' && isValidVideoId('vimeo', id)) { - return 'https://player.vimeo.com/video/' + id; - } - return null; - } - - function firstPathSegment(pathname) { - var parts = pathname.split('/'); - for (var i = 0; i < parts.length; i++) { - if (parts[i]) return parts[i]; - } - return ''; - } - - function youtubeIdFromUrl(u) { - var host = u.hostname.toLowerCase(); - if (YOUTU_BE_HOSTS[host]) { - return firstPathSegment(u.pathname); - } - if (YOUTUBE_HOSTS[host]) { - var v = u.searchParams.get('v'); - if (v) return v; - var m = u.pathname.match(/^\/(?:embed|v|shorts)\/([^/?#]+)/); - if (m) return m[1]; - } - return ''; - } - - function vimeoIdFromUrl(u) { - var host = u.hostname.toLowerCase(); - if (VIMEO_PLAYER_HOSTS[host]) { - var m = u.pathname.match(/^\/video\/([0-9]+)/); - if (m) return m[1]; - } - if (VIMEO_HOSTS[host]) { - return firstPathSegment(u.pathname); - } - return ''; - } - - function descriptor(provider, id, originalUrl, embedUrl, extra) { - var d = { - provider: provider, - providerVideoId: id, - originalUrl: originalUrl, - embedUrl: embedUrl, - aspectRatio: provider === 'pdf' ? undefined : '16:9', - interactive: false, - requiresBridge: provider !== 'pdf', - }; - if (extra && extra.title) d.title = extra.title; - return d; - } - - /** - * Normalize a URL string or an element (iframe/anchor) into a neutral media - * descriptor, or null when it is not a supported external embed. - */ - function parseExternalMedia(input) { - var url = input; - var title; - if (input && typeof input.getAttribute === 'function') { - url = input.getAttribute('src') || input.getAttribute('href') || ''; - title = input.getAttribute('title') || undefined; - } - if (typeof url !== 'string' || !url) return null; - - var u; - try { - u = new URL(url); - } catch (e) { - return null; - } - // Reject userinfo (https://user@host/...) and non-web schemes outright. - if (u.username || u.password) return null; - if (u.protocol !== 'https:' && u.protocol !== 'http:') return null; - - var ytId = youtubeIdFromUrl(u); - if (ytId) { - if (!isValidVideoId('youtube', ytId)) return null; - return descriptor('youtube', ytId, url, canonicalEmbedUrl('youtube', ytId), { title: title }); - } - - var vId = vimeoIdFromUrl(u); - if (vId) { - if (!isValidVideoId('vimeo', vId)) return null; - return descriptor('vimeo', vId, url, canonicalEmbedUrl('vimeo', vId), { title: title }); - } - - if (u.pathname.toLowerCase().slice(-4) === '.pdf') { - return descriptor('pdf', null, url, url, { title: title }); - } - - return null; - } - - function hasEnvelope(d) { - return isObject(d) && d.type === TYPE && d.v === VERSION; - } - - function isHello(d) { - return hasEnvelope(d) && d.action === 'hello' && typeof d.helloId === 'string' && d.helloId.length > 0; - } - - function isWelcome(d) { - return ( - hasEnvelope(d) && - d.action === 'welcome' && - typeof d.helloId === 'string' && - typeof d.exelearningBridge === 'string' && - d.exelearningBridge.length > 0 - ); - } - - /** - * Validate a child→parent command. Defense in depth: envelope + a present, matching - * nonce (the `!!expectedNonce` guard prevents an absent expected nonce from ever - * authenticating a forged message) + closed action enum + per-action payload checks. - */ - function validateCommand(d, expectedNonce) { - if (!hasEnvelope(d)) return false; - if (!expectedNonce) return false; - if (typeof d.exelearningBridge !== 'string' || d.exelearningBridge !== expectedNonce) return false; - if (!COMMANDS[d.action]) return false; - switch (d.action) { - case 'open': - return ( - Number.isInteger(d.reqId) && - isAllowedProvider(d.provider) && - isValidVideoId(d.provider, d.videoId) && - (d.start == null || finiteNonNeg(d.start)) - ); - case 'seek': - return finiteNonNeg(d.t); - case 'getCurrentTime': - case 'getDuration': - return Number.isInteger(d.reqId); - default: - return true; // play / pause / hide / show / close — no payload - } - } - - /** - * Validate a parent→child event. Same envelope + closed enum + per-action payload. - */ - function validateEvent(d) { - if (!hasEnvelope(d)) return false; - if (!EVENTS[d.action]) return false; - switch (d.action) { - case 'timeupdate': - return finiteNonNeg(d.currentTime) && finiteNonNeg(d.duration); - case 'seeked': - return finiteNonNeg(d.currentTime); - case 'state': - return Number.isInteger(d.reqId); - case 'ready': - return d.duration == null || finiteNonNeg(d.duration); - case 'error': - return typeof d.code === 'string' && typeof d.fatal === 'boolean'; - default: - return true; // play / pause / ended / closed - } - } - - var api = { - TYPE: TYPE, - VERSION: VERSION, - COMMANDS: COMMANDS, - EVENTS: EVENTS, - parseExternalMedia: parseExternalMedia, - canonicalEmbedUrl: canonicalEmbedUrl, - isAllowedProvider: isAllowedProvider, - isValidVideoId: isValidVideoId, - isHello: isHello, - isWelcome: isWelcome, - validateCommand: validateCommand, - validateEvent: validateEvent, - }; - - root.exeMediaPolicy = api; -})(typeof window !== 'undefined' ? window : globalThis); diff --git a/src/embed/relay-host.ts b/src/embed/relay-host.ts index a4a837d..4861662 100644 --- a/src/embed/relay-host.ts +++ b/src/embed/relay-host.ts @@ -1,20 +1,14 @@ /** - * Loads the eXe-core external-media bridge into the trusted parent (the Viewer - * page) and drives it. - * - * - Channel A (`exe-embed`): overlays real YouTube/Vimeo/Dailymotion/PDF players - * over the geometry placeholders the shim reports from inside the opaque - * iframe. `exe_embed_relay.js` attaches `window.exeEmbedRelay`. - * - Channel B (`exe-media`): hosts the interactive-video iDevice modal bridge. - * `exe_media_host.js` (+ its `exe_media_policy.js` dependency) attaches - * `window.exeMediaHost`. + * Loads the eXe-core embed relay into the trusted parent (the Viewer page) and + * drives it. * + * It overlays real YouTube/Vimeo/Dailymotion/PDF players over the geometry + * placeholders the shim reports from inside the opaque iframe. + * `exe_embed_relay.js` attaches `window.exeEmbedRelay`. * The shim itself is NOT imported here — the PHP ContentController inlines it * into the served package so it runs inside the opaque iframe. */ import './exe_embed_relay.js' -import './exe_media_policy.js' -import './exe_media_host.js' interface RelayInstance { clear?: () => void @@ -30,16 +24,11 @@ interface EmbedRelayApi { init: (config: EmbedRelayConfig) => RelayInstance } -interface MediaHostApi { - attach: (iframe: HTMLIFrameElement, opts: Record) => void -} - interface BridgeGlobals { exeEmbedRelay?: EmbedRelayApi - exeMediaHost?: MediaHostApi } -/** The bridge globals attached to `window` by the imported mirror scripts. */ +/** The bridge globals attached to `window` by the imported mirror script. */ function globals(): BridgeGlobals { return window as unknown as BridgeGlobals } @@ -70,14 +59,6 @@ export function pingEmbeds(iframe: HTMLIFrameElement): void { iframe.contentWindow?.postMessage({ type: 'exe-embed', action: 'request' }, '*') } -/** - * Attach the interactive-video media host to a content iframe (Channel B). - * @param iframe The opaque content iframe to attach the media host to. - */ -export function attachMedia(iframe: HTMLIFrameElement): void { - globals().exeMediaHost?.attach(iframe, {}) -} - /** Remove all overlay players — call when the iframe is hidden or torn down. */ export function clearOverlays(): void { relayInstance?.clear?.() diff --git a/src/types/shims.d.ts b/src/types/shims.d.ts index 20211fe..7ee748d 100644 --- a/src/types/shims.d.ts +++ b/src/types/shims.d.ts @@ -5,9 +5,7 @@ declare module '*.vue' { } // The eXe-core embed/media bridge mirrors are plain-JS, side-effect modules -// (they attach window.exeEmbedRelay / window.exeMediaHost). We import them only +// (they attach window.exeEmbedRelay). We import them only // for their side effects and use the window globals via typed casts. declare module '*/exe_embed_relay.js' declare module '*/exe_embed_shim.js' -declare module '*/exe_media_policy.js' -declare module '*/exe_media_host.js' diff --git a/src/viewer/ElpxViewer.vue b/src/viewer/ElpxViewer.vue index 6b30622..5c94661 100644 --- a/src/viewer/ElpxViewer.vue +++ b/src/viewer/ElpxViewer.vue @@ -46,7 +46,7 @@ import { ViewerSession } from '../elpx/viewer-session' import { ensureRuntimeWorker, registerSession, unregisterSession, type RuntimeWorker } from '../elpx/service-worker-client' import { createAssetIframe, createContentIframe, createPackageIframe } from '../elpx/iframe-renderer' import { ASSET_PREFIX, CONTENT_PREFIX } from '../elpx/paths' -import { attachMedia, clearOverlays, type EmbedRelayConfig, pingEmbeds, reflowOverlays, startRelay } from '../embed/relay-host' +import { clearOverlays, type EmbedRelayConfig, pingEmbeds, reflowOverlays, startRelay } from '../embed/relay-host' /** * Reads a server-provided initial-state value, falling back on any error so the @@ -234,11 +234,9 @@ export default defineComponent({ const relayConfig = safeLoad('embedRelay', { mode: 'open' }) iframe.addEventListener('load', () => { // The trusted parent overlays real players over the placeholders - // the opaque iframe's shim reports (Channel A) and hosts the - // interactive-video bridge (Channel B). + // the opaque iframe's shim reports. startRelay(relayConfig) pingEmbeds(iframe) - attachMedia(iframe) }) slot.appendChild(iframe) this.iframe = iframe diff --git a/tests/Unit/Service/Preview/FixedResourceManifestTest.php b/tests/Unit/Service/Preview/FixedResourceManifestTest.php deleted file mode 100644 index 249cbb8..0000000 --- a/tests/Unit/Service/Preview/FixedResourceManifestTest.php +++ /dev/null @@ -1,108 +0,0 @@ -distRoot = sys_get_temp_dir() . '/exe-fixed-' . bin2hex(random_bytes(6)); - mkdir($this->distRoot . '/bundles', 0770, true); - mkdir($this->distRoot . '/libs/jquery', 0770, true); - } - - protected function tearDown(): void { - $this->removeTree($this->distRoot); - $this->removeTree(dirname($this->distRoot) . '/exe-outside-' . basename($this->distRoot)); - } - - private function writeManifest(array $resources): void { - file_put_contents( - $this->distRoot . '/bundles/preview-fixed-resources.json', - json_encode(['schemaVersion' => 1, 'buildVersion' => 'test', 'resources' => $resources]), - ); - } - - public function testResolvesEnumeratedResource(): void { - $body = 'window.jQuery=function(){};'; - file_put_contents($this->distRoot . '/libs/jquery/jquery.min.js', $body); - $this->writeManifest([ - 'libs/jquery/jquery.min.js' => ['path' => 'libs/jquery/jquery.min.js', 'size' => strlen($body)], - ]); - $manifest = new FixedResourceManifest($this->distRoot); - - self::assertTrue($manifest->has('libs/jquery/jquery.min.js')); - $resource = $manifest->get('libs/jquery/jquery.min.js'); - self::assertNotNull($resource); - self::assertSame($body, $resource['bytes']); - // get() reports the ACTUAL byte length read from disk, not the manifest's - // declared size (which the server never trusts for serving). - self::assertSame(strlen($body), $resource['size']); - } - - public function testUnknownIdIsNeitherHadNorResolved(): void { - $this->writeManifest(['libs/jquery/jquery.min.js' => ['path' => 'libs/jquery/jquery.min.js']]); - $manifest = new FixedResourceManifest($this->distRoot); - - self::assertFalse($manifest->has('libs/does/not/exist.js')); - self::assertNull($manifest->get('libs/does/not/exist.js')); - } - - public function testAbsentManifestDisablesTheLayer(): void { - // No bundles/preview-fixed-resources.json written. - $manifest = new FixedResourceManifest($this->distRoot); - self::assertFalse($manifest->has('anything')); - self::assertNull($manifest->get('anything')); - } - - public function testMalformedManifestDisablesTheLayer(): void { - file_put_contents($this->distRoot . '/bundles/preview-fixed-resources.json', '{ not json'); - $manifest = new FixedResourceManifest($this->distRoot); - self::assertFalse($manifest->has('anything')); - } - - public function testManifestPathEscapingRootIsRejected(): void { - $outside = dirname($this->distRoot) . '/exe-outside-' . basename($this->distRoot); - file_put_contents($outside, 'SECRET'); - $this->writeManifest(['evil' => ['path' => '../exe-outside-' . basename($this->distRoot)]]); - $manifest = new FixedResourceManifest($this->distRoot); - - // The id is enumerated, but the containment check refuses to read it. - self::assertTrue($manifest->has('evil')); - self::assertNull($manifest->get('evil')); - } - - public function testMissingBackingFileYieldsNull(): void { - $this->writeManifest(['ghost' => ['path' => 'libs/jquery/ghost.js']]); - $manifest = new FixedResourceManifest($this->distRoot); - self::assertTrue($manifest->has('ghost')); - self::assertNull($manifest->get('ghost')); - } - - private function removeTree(string $dir): void { - if (is_file($dir)) { - @unlink($dir); - return; - } - if (!is_dir($dir)) { - return; - } - foreach (scandir($dir) ?: [] as $item) { - if ($item === '.' || $item === '..') { - continue; - } - $this->removeTree($dir . '/' . $item); - } - @rmdir($dir); - } -} diff --git a/tests/Unit/Service/Preview/PreviewContractConformanceTest.php b/tests/Unit/Service/Preview/PreviewContractConformanceTest.php deleted file mode 100644 index 2dedf7c..0000000 --- a/tests/Unit/Service/Preview/PreviewContractConformanceTest.php +++ /dev/null @@ -1,252 +0,0 @@ -root = sys_get_temp_dir() . '/exe-vec-' . bin2hex(random_bytes(6)); - $this->distRoot = sys_get_temp_dir() . '/exe-vec-dist-' . bin2hex(random_bytes(6)); - mkdir($this->distRoot, 0770, true); - } - - protected function tearDown(): void { - $this->removeTree($this->root); - $this->removeTree($this->distRoot); - } - - public function testReplayVectors(): void { - $vectors = json_decode( - (string)file_get_contents(__DIR__ . '/../../../fixtures/preview-contract/vectors.json'), - true, - ); - self::assertSame(2, $vectors['protocolVersion']); - - $fixed = $this->materializeFixedResources($vectors['fixedResources']); - $store = new PreviewSessionStore($this->root, new PreviewSessionLimits()); - $this->api = new PreviewSessionApi($store, $fixed); - $this->server = new PreviewServer($store, $fixed); - - foreach ($vectors['steps'] as $step) { - $this->runStep($step); - } - } - - /** - * Write each fixed resource under a throwaway distribution root and register - * it in the host's manifest copy, then resolve fixed refs through that - * manifest only (harness semantics step 1). - * - * @param array $resources - */ - private function materializeFixedResources(array $resources): FixedResourceManifest { - $manifest = []; - foreach ($resources as $id => $resource) { - $target = $this->distRoot . '/' . $resource['path']; - @mkdir(dirname($target), 0770, true); - file_put_contents($target, $resource['content']); - $manifest[$id] = ['path' => $resource['path'], 'size' => strlen($resource['content'])]; - } - @mkdir($this->distRoot . '/bundles', 0770, true); - file_put_contents( - $this->distRoot . '/bundles/preview-fixed-resources.json', - json_encode(['schemaVersion' => 1, 'resources' => $manifest]), - ); - return new FixedResourceManifest($this->distRoot); - } - - private function runStep(array $step): void { - $id = $step['id']; - $method = $step['request']['method']; - $path = str_replace('{previewId}', $this->previewId, $step['request']['path']); - $expect = $step['expect']; - - if ($method === 'GET') { - $response = $this->dispatchServe($path, $step['request']['headers'] ?? []); - $this->assertServe($id, $response, $expect); - return; - } - $result = $this->dispatchManagement($method, $path, $step['request']['body'] ?? null); - $this->assertManagement($id, $result, $expect); - if ($id === 'create-session') { - $this->previewId = $result->body['previewId']; - } - } - - private function dispatchServe(string $path, array $headers): PreviewResponse { - self::assertSame(1, preg_match('#^/preview/([^/]+)/(.*)$#', $path, $m), "unexpected serve path: $path"); - return $this->server->serve( - $m[1], - $m[2], - $headers['If-None-Match'] ?? null, - $headers['Range'] ?? null, - ); - } - - private function dispatchManagement(string $method, string $path, ?array $body): ApiResult { - if ($method === 'POST' && $path === '/api/preview-session') { - return $this->api->create(self::OWNER); - } - if ($method === 'POST' && str_ends_with($path, '/assets')) { - return $this->api->uploadAssets($this->previewId, self::OWNER, $this->assetEntries($body)); - } - if ($method === 'POST' && str_ends_with($path, '/revisions')) { - return $this->api->publishRevision($this->previewId, self::OWNER, $this->revisionMeta($body)); - } - if ($method === 'DELETE') { - return $this->api->delete($this->previewId, self::OWNER); - } - self::fail("unhandled management request: $method $path"); - } - - /** - * `body.kind === "assets"`: each entry becomes `{ key, size, bytes }` (the - * harness's `size` = byte length of `content`). - * - * @return list - */ - private function assetEntries(?array $body): array { - $entries = []; - foreach ($body['entries'] ?? [] as $entry) { - $entries[] = [ - 'key' => $entry['key'], - 'declaredSize' => strlen($entry['content']), - 'bytes' => $entry['content'], - ]; - } - return $entries; - } - - /** - * `body.kind === "revision"`: `meta` plus `writes` mapped to `{ path, bytes }` - * (the harness sends `writes` as paths + index-aligned file parts). - * - * @return array - */ - private function revisionMeta(?array $body): array { - $meta = $body['meta']; - $writes = []; - foreach ($body['writes'] ?? [] as $write) { - $writes[] = ['path' => $write['path'], 'bytes' => $write['content']]; - } - $meta['writes'] = $writes; - return $meta; - } - - private function assertManagement(string $id, ApiResult $result, array $expect): void { - self::assertSame($expect['status'], $result->status, "status for step $id"); - if (isset($expect['body'])) { - $this->assertBodySubset($expect['body'], $result->body, $id); - } - } - - private function assertServe(string $id, PreviewResponse $response, array $expect): void { - self::assertSame($expect['status'], $response->status, "status for step $id"); - if (isset($expect['bodyText'])) { - self::assertSame($expect['bodyText'], $response->body, "bodyText for step $id"); - } - foreach ($expect['headers'] ?? [] as $name => $spec) { - $this->assertHeader($id, $response->headers, $name, $spec); - } - } - - /** - * @param array $headers - * @param string|array $spec - */ - private function assertHeader(string $id, array $headers, string $name, string|array $spec): void { - $value = null; - $present = false; - foreach ($headers as $key => $headerValue) { - if (strcasecmp($key, $name) === 0) { - $value = $headerValue; - $present = true; - break; - } - } - if (is_string($spec)) { - self::assertTrue($present, "header $name present for step $id"); - self::assertSame($spec, $value, "header $name for step $id"); - return; - } - if (isset($spec['absent'])) { - self::assertFalse($present, "header $name must be absent for step $id"); - return; - } - self::assertTrue($present, "header $name present for step $id"); - if (isset($spec['startsWith'])) { - self::assertStringStartsWith($spec['startsWith'], (string)$value, "header $name startsWith for step $id"); - } - if (isset($spec['contains'])) { - self::assertStringContainsString($spec['contains'], (string)$value, "header $name contains for step $id"); - } - } - - /** - * Recursive subset match: JSON arrays (PHP lists) must match exactly, JSON - * objects (assoc) may carry extra keys. - * - * @param array $expected - * @param array $actual - */ - private function assertBodySubset(array $expected, array $actual, string $id): void { - foreach ($expected as $key => $value) { - self::assertArrayHasKey($key, $actual, "body.$key for step $id"); - if (is_array($value)) { - if (array_is_list($value)) { - self::assertSame($value, $actual[$key], "body.$key (array) for step $id"); - } else { - self::assertIsArray($actual[$key], "body.$key (object) for step $id"); - $this->assertBodySubset($value, $actual[$key], $id); - } - } else { - self::assertSame($value, $actual[$key], "body.$key for step $id"); - } - } - } - - private function removeTree(string $dir): void { - if (!is_dir($dir)) { - @unlink($dir); - return; - } - foreach (scandir($dir) ?: [] as $item) { - if ($item === '.' || $item === '..') { - continue; - } - $this->removeTree($dir . '/' . $item); - } - @rmdir($dir); - } -} diff --git a/tests/Unit/Service/Preview/PreviewServerTest.php b/tests/Unit/Service/Preview/PreviewServerTest.php index 4b7cefd..26fdc27 100644 --- a/tests/Unit/Service/Preview/PreviewServerTest.php +++ b/tests/Unit/Service/Preview/PreviewServerTest.php @@ -4,78 +4,61 @@ namespace OCA\ExeLearning\Tests\Unit\Service\Preview; -use OCA\ExeLearning\Service\Preview\FixedResourceManifest; use OCA\ExeLearning\Service\Preview\PreviewPolicy; use OCA\ExeLearning\Service\Preview\PreviewServer; -use OCA\ExeLearning\Service\Preview\PreviewSessionLimits; -use OCA\ExeLearning\Service\Preview\PreviewSessionStore; +use OCA\ExeLearning\Service\Preview\PreviewSnapshotLimits; +use OCA\ExeLearning\Service\Preview\PreviewSnapshotStore; use PHPUnit\Framework\TestCase; +use ZipArchive; /** * Tests for {@see PreviewServer} — the serving-route HTTP policy: the hardening * header set on every response (404 included), tiered Cache-Control, the sandbox - * CSP on scriptable types from every layer, ETag/304 and single-range 206/416. + * CSP on every scriptable type, ETag/304 and single-range 206/416. */ final class PreviewServerTest extends TestCase { - private const PHOTO_KEY = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57'; - private const CLIP_KEY = '12345678-90ab-4cde-8f01-234567890abc@00112233'; - private string $root; - private string $distRoot; - private PreviewSessionStore $store; - private FixedResourceManifest $fixed; + private PreviewSnapshotStore $store; private string $previewId; protected function setUp(): void { $this->root = sys_get_temp_dir() . '/exe-srv-' . bin2hex(random_bytes(6)); - $this->distRoot = sys_get_temp_dir() . '/exe-srv-dist-' . bin2hex(random_bytes(6)); - mkdir($this->distRoot . '/bundles', 0770, true); - mkdir($this->distRoot . '/base', 0770, true); - file_put_contents($this->distRoot . '/base/jquery.min.js', 'window.jQuery=function(){};'); - file_put_contents( - $this->distRoot . '/base/icon.svg', - '', - ); - file_put_contents($this->distRoot . '/bundles/preview-fixed-resources.json', json_encode([ - 'resources' => [ - 'libs/jquery/jquery.min.js' => ['path' => 'base/jquery.min.js'], - 'theme:base/icon.svg' => ['path' => 'base/icon.svg'], - ], - ])); - $this->fixed = new FixedResourceManifest($this->distRoot); - $this->store = new PreviewSessionStore($this->root, new PreviewSessionLimits()); - - $this->previewId = $this->store->create('alice'); - $this->store->storeAssets($this->previewId, [ - ['key' => self::PHOTO_KEY, 'declaredSize' => 14, 'bytes' => 'PHOTO-BYTES-v1'], - ['key' => self::CLIP_KEY, 'declaredSize' => 10, 'bytes' => '0123456789'], - ]); - $this->store->applyRevision($this->previewId, [ - 'baseRevision' => 0, - 'nextRevision' => 1, - 'writes' => [ - ['path' => 'index.html', 'bytes' => 'hi'], - ['path' => 'img/inline.svg', 'bytes' => ''], - ], - 'deletes' => [], - 'assetRefs' => [ - 'content/resources/photo.png' => self::PHOTO_KEY, - 'media/clip.mp4' => self::CLIP_KEY, - ], - 'fixedRefs' => [ - 'libs/jquery/jquery.min.js' => 'libs/jquery/jquery.min.js', - 'theme/icon.svg' => 'theme:base/icon.svg', - ], - ], $this->fixed); + $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); - $this->removeTree($this->distRoot); + } + + /** + * 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, $this->fixed); + 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 { @@ -101,21 +84,18 @@ public function testServeAssetHasEtagNoCacheAndNoCsp(): void { 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('"' . self::PHOTO_KEY . '"', $response->headers['ETag']); self::assertSame('bytes', $response->headers['Accept-Ranges']); + self::assertArrayHasKey('ETag', $response->headers); self::assertArrayNotHasKey('Content-Security-Policy', $response->headers); } public function testConditionalRequestReturns304(): void { - $response = $this->server()->serve( - $this->previewId, - 'content/resources/photo.png', - '"' . self::PHOTO_KEY . '"', - ); + $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('"' . self::PHOTO_KEY . '"', $response->headers['ETag']); + self::assertSame($etag, $response->headers['ETag']); } public function testSingleRangeReturns206(): void { @@ -149,8 +129,7 @@ public static function unsatisfiableRangeProvider(): array { /** * A malformed, multi-range or non-bytes Range header is IGNORED — the server - * serves a normal 200 full body, never 416. (The previous behaviour of 416 - * on any parse failure was wrong per the serving contract.) + * serves a normal 200 full body, never 416. * * @dataProvider ignoredRangeProvider */ @@ -159,7 +138,6 @@ public function testIgnoredRangeServesFull200(string $range): void { self::assertSame(200, $response->status, $range . ' must be ignored, not 416'); self::assertSame('0123456789', $response->body); self::assertSame('bytes', $response->headers['Accept-Ranges']); - self::assertSame('"' . self::CLIP_KEY . '"', $response->headers['ETag']); self::assertArrayNotHasKey('Content-Range', $response->headers); } @@ -195,23 +173,12 @@ public function testBareRootInvalidPreviewIdIs404(): void { $this->assertBaseHardening($response->headers); } - public function testServeFixedResourceIsAggressivelyCacheable(): void { - $response = $this->server()->serve($this->previewId, 'libs/jquery/jquery.min.js'); - self::assertSame(200, $response->status); - self::assertSame('window.jQuery=function(){};', $response->body); - self::assertSame('private, max-age=31536000', $response->headers['Cache-Control']); - self::assertSame('*', $response->headers['Access-Control-Allow-Origin']); - } - - public function testScriptableSvgFromFixedLayerCarriesCsp(): void { - $response = $this->server()->serve($this->previewId, 'theme/icon.svg'); - self::assertSame(200, $response->status); - self::assertSame('image/svg+xml; charset=utf-8', $response->headers['Content-Type']); - self::assertSame('private, max-age=31536000', $response->headers['Cache-Control']); - self::assertSame(PreviewPolicy::CSP, $response->headers['Content-Security-Policy']); - } - - public function testScriptableSvgFromSessionLayerCarriesCsp(): void { + /** + * An author-supplied SVG runs its inline ', - ); - file_put_contents($this->distRoot . '/bundles/preview-fixed-resources.json', json_encode([ - 'resources' => [ - 'libs/jquery/jquery.min.js' => ['path' => 'libs/jquery/jquery.min.js'], - 'theme:base/icon.svg' => ['path' => 'libs/jquery/icon.svg'], - ], - ])); - return new FixedResourceManifest($this->distRoot); - } - - /** An empty (disabled) fixed layer. */ - private function noFixed(): FixedResourceManifest { - return new FixedResourceManifest($this->distRoot . '/absent'); - } - - private function asset(string $key, string $bytes): array { - return ['key' => $key, 'declaredSize' => strlen($bytes), 'bytes' => $bytes]; - } - - // -- Lifecycle ----------------------------------------------------------- - - public function testCreateAndOwnership(): void { - $store = $this->store(); - $id = $store->create('alice'); - - self::assertTrue($store->exists($id)); - self::assertSame('alice', $store->ownerOf($id)); - self::assertSame(0, $store->activeRevision($id)); - self::assertMatchesRegularExpression( - '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', - $id, - ); - } - - public function testDeleteRemovesSession(): void { - $store = $this->store(); - $id = $store->create('alice'); - self::assertTrue($store->delete($id)); - self::assertFalse($store->exists($id)); - self::assertFalse($store->delete($id)); - self::assertNull($store->ownerOf($id)); - } - - public function testPerUserSessionCapEvictsLeastRecentlyUsed(): void { - $store = $this->store(new PreviewSessionLimits(maxSessionsPerUser: 2)); - $this->now = 1000; - $first = $store->create('alice'); - $this->now = 1001; - $second = $store->create('alice'); - $this->now = 1002; - $third = $store->create('alice'); - - self::assertFalse($store->exists($first), 'oldest owned session evicted'); - self::assertTrue($store->exists($second)); - self::assertTrue($store->exists($third)); - // A different user is unaffected by alice's cap. - $bob = $store->create('bob'); - self::assertTrue($store->exists($bob)); - } - - // -- Assets -------------------------------------------------------------- - - public function testStoreAssetsAndImmutability(): void { - $store = $this->store(); - $id = $store->create('alice'); - $key = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57'; - - $first = $store->storeAssets($id, [$this->asset($key, 'PHOTO-BYTES-v1')]); - self::assertSame([$key], $first['stored']); - self::assertSame([], $first['alreadyStored']); - - // Re-upload the SAME key with DIFFERENT bytes → alreadyStored, not replaced. - $second = $store->storeAssets($id, [$this->asset($key, 'PHOTO-BYTES-v2-DIFFERENT')]); - self::assertSame([], $second['stored']); - self::assertSame([$key], $second['alreadyStored']); - - // Publish a revision referencing it and prove the ORIGINAL bytes serve. - $this->publish($store, $id, 0, 1, [], ['content/photo.png' => $key], []); - $file = $store->resolve($id, 'content/photo.png', $this->noFixed()); - self::assertNotNull($file); - self::assertSame($key, $file['etag']); - self::assertSame('PHOTO-BYTES-v1', file_get_contents($file['filePath'])); - } - - public function testStoreAssetsRejections(): void { - $store = $this->store(new PreviewSessionLimits(maxAssetBytes: 8, maxBytesPerSession: 20)); - $id = $store->create('alice'); - $goodKey = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57'; - $bigKey = 'bbbbbbbb-cccc-4ddd-8eee-ffff00001111@0011223344'; - - $result = $store->storeAssets($id, [ - ['key' => 'not-a-valid-key', 'declaredSize' => 3, 'bytes' => 'abc'], - ['key' => $goodKey, 'declaredSize' => 99, 'bytes' => 'abc'], - $this->asset($bigKey, 'TOO-LONG-BYTES'), - ]); - - $reasons = []; - foreach ($result['rejected'] as $entry) { - $reasons[$entry['key']] = $entry['reason']; - } - self::assertSame('invalid-key', $reasons['not-a-valid-key']); - self::assertSame('size-mismatch', $reasons[$goodKey]); - self::assertSame('asset-too-large', $reasons[$bigKey]); - self::assertSame([], $result['stored']); - } - - public function testBlobWriteFailureIsRejectedNotAlreadyStored(): void { - $store = $this->store(); - $id = $store->create('alice'); - $key = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57'; - - // Force the underlying blob write to fail deterministically (and - // root-independently): replace the session's assets/ directory with a - // regular file, so any write under it fails with ENOTDIR. - $assetsPath = $this->root . '/' . $id . '/assets'; - $this->removeTree($assetsPath); - file_put_contents($assetsPath, 'not-a-directory'); - - $result = $store->storeAssets($id, [$this->asset($key, 'PHOTO-BYTES')]); - - // A write failure must be REJECTED, never stored/alreadyStored: reporting - // alreadyStored would make the client mark the key uploaded, stranding the - // asset as a permanent 404 that the missing-assets loop can never repair. - self::assertSame([], $result['stored']); - self::assertSame([], $result['alreadyStored']); - self::assertSame([['key' => $key, 'reason' => 'write-failed']], $result['rejected']); - - // The blob is genuinely absent... - self::assertFileDoesNotExist($assetsPath . '/' . sha1($key)); - - // ...so a revision referencing it returns 422 missing-assets (the client's - // recovery path then re-uploads it), instead of a silent permanent 404. - $revision = $store->applyRevision($id, [ - 'baseRevision' => 0, - 'nextRevision' => 1, - 'writes' => [], - 'deletes' => [], - 'assetRefs' => ['content/photo.png' => $key], - 'fixedRefs' => [], - ], $this->noFixed()); - self::assertSame(422, $revision['status']); - self::assertSame('missing-assets', $revision['reason']); - self::assertSame([$key], $revision['missing']); - } - - // -- Revisions ----------------------------------------------------------- - - public function testPublishRevisionHappyPath(): void { - $store = $this->store(); - $fixed = $this->fixedManifest(); - $id = $store->create('alice'); - $key = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57'; - $store->storeAssets($id, [$this->asset($key, 'PHOTO')]); - - $result = $store->applyRevision($id, [ - 'baseRevision' => 0, - 'nextRevision' => 1, - 'writes' => [ - ['path' => 'index.html', 'bytes' => 'hi'], - ['path' => 'img/inline.svg', 'bytes' => ''], - ], - 'deletes' => [], - 'assetRefs' => ['content/photo.png' => $key], - 'fixedRefs' => ['libs/jquery/jquery.min.js' => 'libs/jquery/jquery.min.js'], - ], $fixed); - - self::assertSame(200, $result['status']); - self::assertSame(1, $result['revision']); - self::assertSame(1, $store->activeRevision($id)); - - $doc = $store->resolve($id, 'index.html', $fixed); - self::assertSame('document', $doc['kind']); - self::assertSame('hi', $doc['bytes']); - - $fixedFile = $store->resolve($id, 'libs/jquery/jquery.min.js', $fixed); - self::assertSame('fixed', $fixedFile['kind']); - self::assertSame('window.jQuery=function(){};', $fixedFile['bytes']); - } - - public function testStaleBaseRevisionConflicts(): void { - $store = $this->store(); - $id = $store->create('alice'); - $this->publish($store, $id, 0, 1, [['path' => 'index.html', 'bytes' => 'v1']], [], []); - - $result = $store->applyRevision($id, [ - 'baseRevision' => 0, - 'nextRevision' => 1, - 'writes' => [['path' => 'index.html', 'bytes' => 'stale']], - 'deletes' => [], - 'assetRefs' => [], - 'fixedRefs' => [], - ], $this->noFixed()); - - self::assertSame(409, $result['status']); - self::assertSame(1, $result['currentRevision']); - // The active revision is untouched by the rejected apply (atomicity). - self::assertSame('v1', $store->resolve($id, 'index.html', $this->noFixed())['bytes']); - } - - public function testUnsafeWritePathRejected(): void { - $store = $this->store(); - $id = $store->create('alice'); - $result = $store->applyRevision($id, [ - 'baseRevision' => 0, - 'nextRevision' => 1, - 'writes' => [['path' => '../escape.html', 'bytes' => 'x']], - 'deletes' => [], - 'assetRefs' => [], - 'fixedRefs' => [], - ], $this->noFixed()); - self::assertSame(400, $result['status']); - self::assertSame(0, $store->activeRevision($id)); - } - - public function testMissingAssetRefRejected(): void { - $store = $this->store(); - $id = $store->create('alice'); - $ghost = '99999999-9999-4999-8999-999999999999@deadbeef'; - $result = $store->applyRevision($id, [ - 'baseRevision' => 0, - 'nextRevision' => 1, - 'writes' => [], - 'deletes' => [], - 'assetRefs' => ['content/ghost.png' => $ghost], - 'fixedRefs' => [], - ], $this->noFixed()); - self::assertSame(422, $result['status']); - self::assertSame('missing-assets', $result['reason']); - self::assertSame([$ghost], $result['missing']); - } - - public function testUnknownFixedRefRejected(): void { - $store = $this->store(); - $id = $store->create('alice'); - $result = $store->applyRevision($id, [ - 'baseRevision' => 0, - 'nextRevision' => 1, - 'writes' => [], - 'deletes' => [], - 'assetRefs' => [], - 'fixedRefs' => ['theme/x.css' => 'theme:not-in-manifest'], - ], $this->noFixed()); - self::assertSame(422, $result['status']); - self::assertSame('unknown-fixed-resources', $result['reason']); - self::assertSame(['theme:not-in-manifest'], $result['resources']); - } - - public function testFileCountBudgetRejected(): void { - $store = $this->store(new PreviewSessionLimits(maxFilesPerSession: 1)); - $id = $store->create('alice'); - $result = $store->applyRevision($id, [ - 'baseRevision' => 0, - 'nextRevision' => 1, - 'writes' => [ - ['path' => 'a.html', 'bytes' => 'a'], - ['path' => 'b.html', 'bytes' => 'b'], - ], - 'deletes' => [], - 'assetRefs' => [], - 'fixedRefs' => [], - ], $this->noFixed()); - self::assertSame(413, $result['status']); - } - - public function testRevisionDocumentWriteFailureAbortsBeforePointerSwap(): void { - $store = $this->store(); - $id = $store->create('alice'); - // Establish revision 1. - $this->publish($store, $id, 0, 1, [['path' => 'index.html', 'bytes' => 'v1']], [], []); - - // Force the next document blob write to fail deterministically: replace - // the documents/ directory with a regular file so any write under it - // fails with ENOTDIR. - $documentsPath = $this->root . '/' . $id . '/documents'; - $this->removeTree($documentsPath); - file_put_contents($documentsPath, 'not-a-directory'); - - $result = $store->applyRevision($id, [ - 'baseRevision' => 1, - 'nextRevision' => 2, - 'writes' => [['path' => 'index.html', 'bytes' => 'v2-NEW-UNIQUE-BYTES']], - 'deletes' => [], - 'assetRefs' => [], - 'fixedRefs' => [], - ], $this->noFixed()); - - // The publish aborts with 500 and never advances the pointer, so the - // previously active revision stays intact (no silent 404 document). - self::assertSame(500, $result['status']); - self::assertSame(1, $store->activeRevision($id)); - self::assertFileDoesNotExist($this->root . '/' . $id . '/revisions/2.json'); - } - - public function testSupersededRevisionsArePrunedToBoundDisk(): void { - $store = $this->store(); - $id = $store->create('alice'); - // An asset that must survive every revision (shared/immutable across them). - $key = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57'; - $store->storeAssets($id, [$this->asset($key, str_repeat('A', 4096))]); - - // Publish several revisions, each replacing index.html with UNIQUE content - // (unique hash => a new blob every time; content-addressing cannot mask the - // leak). Without pruning, physical disk grows with revision count even though - // the active-revision budget stays flat — the disk-fill DoS. - $docSize = 50000; - $revisions = 6; - for ($rev = 1; $rev <= $revisions; $rev++) { - $unique = str_repeat('x', $docSize) . '-rev-' . $rev; - $this->publish($store, $id, $rev - 1, $rev, [ - ['path' => 'index.html', 'bytes' => $unique], - ], ['content/photo.png' => $key], []); - } - - $sessionDir = $this->root . '/' . $id; - - // (a) Only the active revision's manifest and its single document blob remain. - $manifests = array_values(array_filter( - scandir($sessionDir . '/revisions') ?: [], - static fn (string $f): bool => preg_match('/^\d+\.json$/', $f) === 1, - )); - self::assertSame([$revisions . '.json'], $manifests, 'superseded revision manifests must be pruned'); - - $blobs = array_values(array_filter( - scandir($sessionDir . '/documents') ?: [], - static fn (string $f): bool => preg_match('/^[0-9a-f]{40}$/', $f) === 1, - )); - self::assertCount(1, $blobs, 'superseded document blobs must be pruned'); - - // The active revision still serves correctly after pruning. - $doc = $store->resolve($id, 'index.html', $this->noFixed()); - self::assertNotNull($doc); - self::assertSame($docSize + strlen('-rev-' . $revisions), strlen($doc['bytes'])); - - // (b) The asset persists across every revision. - $assetFile = $store->resolve($id, 'content/photo.png', $this->noFixed()); - self::assertNotNull($assetFile); - self::assertSame(str_repeat('A', 4096), file_get_contents($assetFile['filePath'])); - - // (c) On-disk bytes stay bounded (~one document + one asset), not revisions x docSize. - $onDisk = $this->diskBytes($sessionDir); - self::assertLessThan($store->limits()->maxBytesPerSession, $onDisk); - self::assertLessThan($docSize * 3, $onDisk, 'retained disk must not grow with revision count'); - } - - public function testIncrementalDeltaAndDeletes(): void { - $store = $this->store(); - $id = $store->create('alice'); - $this->publish($store, $id, 0, 1, [ - ['path' => 'index.html', 'bytes' => 'home'], - ['path' => 'gone.html', 'bytes' => 'temp'], - ], [], []); - // Revision 2: rewrite index.html, delete gone.html. - $this->publish($store, $id, 1, 2, [['path' => 'index.html', 'bytes' => 'home-v2']], [], [], ['gone.html']); - - self::assertSame(2, $store->activeRevision($id)); - self::assertSame('home-v2', $store->resolve($id, 'index.html', $this->noFixed())['bytes']); - self::assertNull($store->resolve($id, 'gone.html', $this->noFixed())); - } - - // -- Resolution edge cases ---------------------------------------------- - - public function testResolveBeforeFirstRevisionIsNull(): void { - $store = $this->store(); - $id = $store->create('alice'); - self::assertNull($store->resolve($id, 'index.html', $this->noFixed())); - } - - public function testResolveUnknownPathIsNull(): void { - $store = $this->store(); - $id = $store->create('alice'); - $this->publish($store, $id, 0, 1, [['path' => 'index.html', 'bytes' => 'x']], [], []); - self::assertNull($store->resolve($id, 'nope.css', $this->noFixed())); - self::assertNull($store->resolve($id, '../escape', $this->noFixed())); - } - - public function testResolveOnMissingSessionIsNull(): void { - $store = $this->store(); - self::assertNull($store->resolve('3f2a1b4c-5d6e-4f70-8a90-b1c2d3e4f506', 'index.html', $this->noFixed())); - } - - // -- TTL ----------------------------------------------------------------- - - public function testExpiredSessionIsDroppedOnAccessAndSwept(): void { - $store = $this->store(new PreviewSessionLimits(ttlSeconds: 100)); - $this->now = 1000; - $id = $store->create('alice'); - $this->publish($store, $id, 0, 1, [['path' => 'index.html', 'bytes' => 'x']], [], []); - - // Within TTL: still served. - $this->now = 1050; - self::assertNotNull($store->resolve($id, 'index.html', $this->noFixed())); - - // Past TTL: opportunistically dropped on access. - $this->now = 2000; - self::assertNull($store->resolve($id, 'index.html', $this->noFixed())); - self::assertFalse($store->exists($id)); - } - - public function testSweepExpiredRemovesIdleSessions(): void { - $store = $this->store(new PreviewSessionLimits(ttlSeconds: 100)); - $this->now = 1000; - $stale = $store->create('alice'); - $this->now = 1900; - $fresh = $store->create('bob'); - - $this->now = 1950; // stale is 950s idle, fresh is 50s idle - self::assertSame(1, $store->sweepExpired()); - self::assertFalse($store->exists($stale)); - self::assertTrue($store->exists($fresh)); - } - - public function testMissingAccessedMarkerFallsBackToCreatedAtForTtl(): void { - // A lost `.accessed` marker (crash between touch and fsync, manual fs - // surgery, backup restore) must NOT make a session immortal: the age - // clock falls back to meta.json createdAt so the session still expires. - $store = $this->store(new PreviewSessionLimits(ttlSeconds: 100)); - $this->now = 1000; - $id = $store->create('alice'); - $this->publish($store, $id, 0, 1, [['path' => 'index.html', 'bytes' => str_repeat('x', 128)]], [], []); - unlink($this->root . '/' . $id . '/.accessed'); - - // Within TTL of createdAt (1000): not yet expired. - $this->now = 1050; - self::assertSame(0, $store->sweepExpired()); - self::assertTrue($store->exists($id)); - - // Past TTL of createdAt: swept, and the directory is physically gone — so - // its bytes no longer contribute to the recomputed global byte budget. - $this->now = 1200; - self::assertSame(1, $store->sweepExpired()); - self::assertFalse($store->exists($id)); - self::assertDirectoryDoesNotExist($this->root . '/' . $id); - } - - public function testCorruptSessionDirectoryIsReclaimed(): void { - // A directory that looks like a session id but has neither `.accessed` - // nor a readable meta.json can never be owned or served — it must be - // reclaimed rather than lingering forever. - $store = $this->store(new PreviewSessionLimits(ttlSeconds: 100)); - $this->now = 1000; - $corruptId = '3f2a1b4c-5d6e-4f70-8a90-b1c2d3e4f506'; - mkdir($this->root . '/' . $corruptId, 0770, true); - - self::assertTrue($store->isExpired($corruptId)); - self::assertSame(1, $store->sweepExpired()); - self::assertDirectoryDoesNotExist($this->root . '/' . $corruptId); - } - - // -- Helpers ------------------------------------------------------------- - - /** - * @param list $writes - * @param array $assetRefs - * @param array $fixedRefs - * @param list $deletes - */ - private function publish( - PreviewSessionStore $store, - string $id, - int $base, - int $next, - array $writes, - array $assetRefs, - array $fixedRefs, - array $deletes = [], - ): void { - $result = $store->applyRevision($id, [ - 'baseRevision' => $base, - 'nextRevision' => $next, - 'writes' => $writes, - 'deletes' => $deletes, - 'assetRefs' => $assetRefs, - 'fixedRefs' => $fixedRefs, - ], $this->noFixed()); - self::assertSame(200, $result['status'], 'publish helper expects success'); - } - - private function removeTree(string $dir): void { - if (!is_dir($dir)) { - @unlink($dir); - return; - } - foreach (scandir($dir) ?: [] as $item) { - if ($item === '.' || $item === '..') { - continue; - } - $this->removeTree($dir . '/' . $item); - } - @rmdir($dir); - } - - /** Total physical bytes stored under a directory tree. */ - private function diskBytes(string $dir): int { - $total = 0; - foreach (scandir($dir) ?: [] as $item) { - if ($item === '.' || $item === '..') { - continue; - } - $path = $dir . '/' . $item; - $total += is_dir($path) ? $this->diskBytes($path) : (int)filesize($path); - } - return $total; - } -} diff --git a/tests/Unit/Service/Preview/PreviewSnapshotStoreTest.php b/tests/Unit/Service/Preview/PreviewSnapshotStoreTest.php new file mode 100644 index 0000000..508cc0a --- /dev/null +++ b/tests/Unit/Service/Preview/PreviewSnapshotStoreTest.php @@ -0,0 +1,280 @@ +root = sys_get_temp_dir() . '/exe-snap-' . bin2hex(random_bytes(6)); + } + + protected function tearDown(): void { + $this->removeTree($this->root); + } + + private function store(?PreviewSnapshotLimits $limits = null): PreviewSnapshotStore { + return new PreviewSnapshotStore( + $this->root, + $limits ?? new PreviewSnapshotLimits(), + fn (): int => $this->time, + ); + } + + /** + * 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-snap-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; + } + + // ========================================================================= + // Publication + // ========================================================================= + + public function testReplaceStoresTheSnapshot(): void { + $store = $this->store(); + $result = $store->replace('alice', $this->zip([ + 'index.html' => 'hello', + 'assets/app.js' => 'run()', + ])); + + self::assertArrayHasKey('previewId', $result); + self::assertSame('hello', $store->resolve($result['previewId'], 'index.html')['bytes']); + self::assertSame('alice', $store->ownerOf($result['previewId'])); + } + + public function testReplaceSwapsTheWholeTree(): void { + $store = $this->store(); + $id = $store->replace('alice', $this->zip([ + 'index.html' => 'first', + 'stale.html' => 'gone next time', + ]))['previewId']; + + $second = $store->replace('alice', $this->zip(['index.html' => 'second']), $id); + + self::assertSame($id, $second['previewId']); + self::assertSame('second', $store->resolve($id, 'index.html')['bytes']); + self::assertNull($store->resolve($id, 'stale.html')); + } + + public function testReplaceRefusesACapabilityOwnedByAnotherUser(): void { + $store = $this->store(); + $id = $store->replace('alice', $this->zip(['index.html' => 'hers']))['previewId']; + + $result = $store->replace('mallory', $this->zip(['index.html' => 'theirs']), $id); + + self::assertSame(403, $result['status']); + self::assertSame('hers', $store->resolve($id, 'index.html')['bytes']); + } + + public function testReplaceRefusesAnUnknownCapability(): void { + $result = $this->store()->replace( + 'alice', + $this->zip(['index.html' => 'ok']), + 'ffffffff-ffff-4fff-bfff-ffffffffffff', + ); + + self::assertSame(404, $result['status']); + } + + public function testDeleteIsOwnerScoped(): void { + $store = $this->store(); + $id = $store->replace('alice', $this->zip(['index.html' => 'ok']))['previewId']; + + self::assertFalse($store->delete($id, 'mallory')); + self::assertNotNull($store->resolve($id, 'index.html')); + + self::assertTrue($store->delete($id, 'alice')); + self::assertNull($store->resolve($id, 'index.html')); + } + + // ========================================================================= + // Archive guards + // ========================================================================= + + public function testArchiveMustCarryAnIndex(): void { + $result = $this->store()->replace('alice', $this->zip(['page.html' => 'orphan'])); + + self::assertSame(400, $result['status']); + self::assertStringContainsString('index.html', $result['error']); + } + + public function testTraversalIsRefusedBeforeAnythingIsWritten(): void { + $result = $this->store()->replace('alice', $this->zip([ + 'index.html' => 'ok', + '../escape.html' => 'nope', + ])); + + self::assertSame(400, $result['status']); + self::assertFileDoesNotExist(dirname($this->root) . '/escape.html'); + } + + public function testAbsolutePathIsRefused(): void { + $result = $this->store()->replace('alice', $this->zip([ + 'index.html' => 'ok', + '/etc/passwd' => 'nope', + ])); + + self::assertSame(400, $result['status']); + } + + public function testTheEntryCountGuardFailsClosed(): void { + $result = $this->store(new PreviewSnapshotLimits(maxFilesPerSnapshot: 1))->replace('alice', $this->zip([ + 'index.html' => 'a', + 'b.html' => 'b', + ])); + + self::assertSame(400, $result['status']); + } + + public function testTheByteGuardMeasuresRealDecompressedBytes(): void { + $result = $this->store(new PreviewSnapshotLimits(maxBytesPerSnapshot: 8))->replace('alice', $this->zip([ + 'index.html' => str_repeat('x', 64), + ])); + + self::assertSame(400, $result['status']); + } + + public function testARejectedUploadLeavesNoStagingBehind(): void { + $this->store()->replace('alice', $this->zip(['page.html' => 'no index'])); + + $leftovers = array_filter( + scandir($this->root) ?: [], + static fn (string $entry): bool => str_starts_with($entry, '.staging-'), + ); + self::assertSame([], array_values($leftovers)); + } + + // ========================================================================= + // TTL and budgets + // ========================================================================= + + public function testIdleSnapshotsExpireAndAreSwept(): void { + $store = $this->store(); + $id = $store->replace('alice', $this->zip(['index.html' => 'ok']))['previewId']; + + $this->time += (new PreviewSnapshotLimits())->ttlSeconds + 60; + + self::assertNull($store->resolve($id, 'index.html')); + self::assertSame(1, $store->sweepExpired()); + } + + public function testServingPushesTheIdleClockBack(): void { + $store = $this->store(); + $ttl = (new PreviewSnapshotLimits())->ttlSeconds; + $id = $store->replace('alice', $this->zip(['index.html' => 'ok']))['previewId']; + + // Just short of the TTL: resolving must keep it alive past the original + // deadline, so a preview in use never expires under the author. + $this->time += $ttl - 60; + self::assertNotNull($store->resolve($id, 'index.html')); + + $this->time += 120; + self::assertNotNull($store->resolve($id, 'index.html')); + } + + /** + * A shared instance must not let one author accumulate capabilities: past + * the per-user cap the least-recently-used one is evicted. + */ + public function testThePerUserCapEvictsTheLeastRecentlyUsed(): void { + $store = $this->store(new PreviewSnapshotLimits(maxSnapshotsPerUser: 2)); + $first = $store->replace('alice', $this->zip(['index.html' => '1']))['previewId']; + $this->time += 10; + $second = $store->replace('alice', $this->zip(['index.html' => '2']))['previewId']; + $this->time += 10; + + $third = $store->replace('alice', $this->zip(['index.html' => '3']))['previewId']; + + self::assertNull($store->resolve($first, 'index.html'), 'the LRU snapshot must be evicted'); + self::assertNotNull($store->resolve($second, 'index.html')); + self::assertNotNull($store->resolve($third, 'index.html')); + } + + /** Another user's snapshots are never touched by this user's cap. */ + public function testThePerUserCapIsScopedToItsOwner(): void { + $store = $this->store(new PreviewSnapshotLimits(maxSnapshotsPerUser: 1)); + $hers = $store->replace('alice', $this->zip(['index.html' => 'hers']))['previewId']; + $this->time += 10; + + $store->replace('bob', $this->zip(['index.html' => 'his'])); + + self::assertNotNull($store->resolve($hers, 'index.html')); + } + + public function testTheGlobalBudgetRefusesWhatItCannotFit(): void { + $store = $this->store(new PreviewSnapshotLimits(globalMaxBytes: 4)); + + $result = $store->replace('alice', $this->zip(['index.html' => str_repeat('x', 64)])); + + self::assertSame(507, $result['status']); + } + + // ========================================================================= + // Serving + // ========================================================================= + + public function testResolveRejectsTraversalOutOfTheSnapshot(): void { + $store = $this->store(); + $id = $store->replace('alice', $this->zip(['index.html' => 'ok']))['previewId']; + + self::assertNull($store->resolve($id, '../meta.json')); + self::assertNull($store->resolve($id, '%2e%2e%2fmeta.json')); + } + + /** + * The store's own metadata lives outside the extracted tree, so an author + * path can never name it — there are no reserved names to police. + */ + public function testStoreMetadataIsNotReachableFromTheServingRoute(): void { + $store = $this->store(); + $id = $store->replace('alice', $this->zip([ + 'index.html' => 'ok', + 'meta.json' => '{"ownerUserId":"mallory"}', + ]))['previewId']; + + // The author's own meta.json is served as ordinary content... + $served = $store->resolve($id, 'meta.json'); + self::assertSame('{"ownerUserId":"mallory"}', file_get_contents($served['filePath'])); + // ...while the store keeps reading the real owner from outside the tree. + self::assertSame('alice', $store->ownerOf($id)); + } + + private function removeTree(string $dir): void { + if (!is_dir($dir)) { + @unlink($dir); + return; + } + foreach (scandir($dir) ?: [] as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $this->removeTree($dir . '/' . $item); + } + @rmdir($dir); + } +} diff --git a/tests/fixtures/preview-contract/vectors.json b/tests/fixtures/preview-contract/vectors.json deleted file mode 100644 index c98880d..0000000 --- a/tests/fixtures/preview-contract/vectors.json +++ /dev/null @@ -1,299 +0,0 @@ -{ - "description": "Machine-readable conformance vectors for eXeLearning preview serving contract v2 (doc/development/preview-serving-contract.md). Replay every step, in order, against a host implementation. See README.md for harness semantics.", - "protocolVersion": 2, - "fixedResources": { - "libs/jquery/jquery.min.js": { - "path": "libs/jquery/jquery.min.js", - "content": "window.jQuery=function(){};" - }, - "theme:base/icon.svg": { - "path": "files/perm/themes/base/base/icon.svg", - "content": "" - } - }, - "constants": { - "photoKey": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57", - "clipKey": "12345678-90ab-4cde-8f01-234567890abc@00112233" - }, - "steps": [ - { - "id": "create-session", - "request": { "method": "POST", "path": "/api/preview-session" }, - "expect": { - "status": 201, - "body": { "protocolVersion": 2, "revision": 0 } - } - }, - { - "id": "upload-assets", - "request": { - "method": "POST", - "path": "/api/preview-session/{previewId}/assets", - "body": { - "kind": "assets", - "entries": [ - { "key": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57", "content": "PHOTO-BYTES-v1" }, - { "key": "12345678-90ab-4cde-8f01-234567890abc@00112233", "content": "0123456789" } - ] - } - }, - "expect": { - "status": 200, - "body": { - "stored": [ - "aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57", - "12345678-90ab-4cde-8f01-234567890abc@00112233" - ], - "alreadyStored": [], - "rejected": [] - } - } - }, - { - "id": "reupload-asset-immutability", - "comment": "Same key, DIFFERENT bytes: reported alreadyStored, bytes NOT replaced (asserted later by serve-asset).", - "request": { - "method": "POST", - "path": "/api/preview-session/{previewId}/assets", - "body": { - "kind": "assets", - "entries": [ - { - "key": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57", - "content": "PHOTO-BYTES-v2-DIFFERENT" - } - ] - } - }, - "expect": { - "status": 200, - "body": { - "stored": [], - "alreadyStored": ["aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57"], - "rejected": [] - } - } - }, - { - "id": "publish-revision-1", - "request": { - "method": "POST", - "path": "/api/preview-session/{previewId}/revisions", - "body": { - "kind": "revision", - "meta": { - "baseRevision": 0, - "nextRevision": 1, - "deletes": [], - "assetRefs": { - "content/resources/photo.png": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57", - "media/clip.mp4": "12345678-90ab-4cde-8f01-234567890abc@00112233" - }, - "fixedRefs": { - "libs/jquery/jquery.min.js": "libs/jquery/jquery.min.js", - "theme/icon.svg": "theme:base/icon.svg" - } - }, - "writes": [ - { "path": "index.html", "content": "conformance" }, - { - "path": "img/inline.svg", - "content": "" - } - ] - } - }, - "expect": { "status": 200, "body": { "revision": 1, "active": true } } - }, - { - "id": "serve-document", - "request": { "method": "GET", "path": "/preview/{previewId}/index.html" }, - "expect": { - "status": 200, - "bodyText": "conformance", - "headers": { - "Content-Type": "text/html; charset=utf-8", - "Cache-Control": "no-store", - "X-Content-Type-Options": "nosniff", - "Referrer-Policy": "no-referrer", - "Access-Control-Allow-Origin": "*", - "Content-Security-Policy": { "startsWith": "sandbox allow-scripts allow-popups allow-forms" } - } - } - }, - { - "id": "serve-asset", - "comment": "Bytes are the ORIGINAL upload — proves reupload-asset-immutability did not replace them.", - "request": { "method": "GET", "path": "/preview/{previewId}/content/resources/photo.png" }, - "expect": { - "status": 200, - "bodyText": "PHOTO-BYTES-v1", - "headers": { - "Content-Type": "image/png", - "Cache-Control": "no-cache", - "ETag": "\"aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57\"", - "Accept-Ranges": "bytes", - "Content-Security-Policy": { "absent": true } - } - } - }, - { - "id": "serve-asset-304", - "request": { - "method": "GET", - "path": "/preview/{previewId}/content/resources/photo.png", - "headers": { "If-None-Match": "\"aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57\"" } - }, - "expect": { - "status": 304, - "headers": { "X-Content-Type-Options": "nosniff" } - } - }, - { - "id": "serve-asset-range", - "request": { - "method": "GET", - "path": "/preview/{previewId}/media/clip.mp4", - "headers": { "Range": "bytes=2-4" } - }, - "expect": { - "status": 206, - "bodyText": "234", - "headers": { "Content-Range": "bytes 2-4/10", "Content-Length": "3" } - } - }, - { - "id": "serve-asset-range-unsatisfiable", - "request": { - "method": "GET", - "path": "/preview/{previewId}/media/clip.mp4", - "headers": { "Range": "bytes=99-" } - }, - "expect": { - "status": 416, - "headers": { "Content-Range": "bytes */10" } - } - }, - { - "id": "serve-fixed", - "request": { "method": "GET", "path": "/preview/{previewId}/libs/jquery/jquery.min.js" }, - "expect": { - "status": 200, - "bodyText": "window.jQuery=function(){};", - "headers": { - "Cache-Control": "private, max-age=31536000", - "X-Content-Type-Options": "nosniff", - "Access-Control-Allow-Origin": "*" - } - } - }, - { - "id": "serve-fixed-scriptable-svg", - "comment": "The sandbox CSP must be emitted on scriptable types from EVERY layer, fixed included.", - "request": { "method": "GET", "path": "/preview/{previewId}/theme/icon.svg" }, - "expect": { - "status": 200, - "headers": { - "Content-Type": "image/svg+xml; charset=utf-8", - "Cache-Control": "private, max-age=31536000", - "Content-Security-Policy": { "startsWith": "sandbox allow-scripts allow-popups allow-forms" } - } - } - }, - { - "id": "serve-session-scriptable-svg", - "request": { "method": "GET", "path": "/preview/{previewId}/img/inline.svg" }, - "expect": { - "status": 200, - "headers": { - "Content-Type": "image/svg+xml; charset=utf-8", - "Content-Security-Policy": { "startsWith": "sandbox allow-scripts allow-popups allow-forms" } - } - } - }, - { - "id": "serve-unknown-404", - "request": { "method": "GET", "path": "/preview/{previewId}/nope.css" }, - "expect": { - "status": 404, - "headers": { - "Cache-Control": "no-store", - "X-Content-Type-Options": "nosniff", - "Access-Control-Allow-Origin": "*" - } - } - }, - { - "id": "serve-traversal-raw", - "comment": "A literal ../ is normalized away by URL parsing before it reaches the server; whatever survives must still 404.", - "request": { "method": "GET", "path": "/preview/{previewId}/../secret" }, - "expect": { "status": 404 } - }, - { - "id": "serve-traversal-encoded", - "comment": "Percent-encoded traversal reaches the server verbatim and must be rejected by path normalization.", - "request": { "method": "GET", "path": "/preview/{previewId}/%2e%2e%2fsecret" }, - "expect": { "status": 404, "headers": { "Cache-Control": "no-store" } } - }, - { - "id": "conflicting-revision-409", - "comment": "Stale baseRevision (client believes 0, server is at 1).", - "request": { - "method": "POST", - "path": "/api/preview-session/{previewId}/revisions", - "body": { - "kind": "revision", - "meta": { - "baseRevision": 0, - "nextRevision": 1, - "deletes": [], - "assetRefs": {}, - "fixedRefs": {} - }, - "writes": [{ "path": "index.html", "content": "stale" }] - } - }, - "expect": { - "status": 409, - "body": { "reason": "revision-conflict", "currentRevision": 1 } - } - }, - { - "id": "missing-asset-revision-422", - "request": { - "method": "POST", - "path": "/api/preview-session/{previewId}/revisions", - "body": { - "kind": "revision", - "meta": { - "baseRevision": 1, - "nextRevision": 2, - "deletes": [], - "assetRefs": { - "content/resources/ghost.png": "99999999-9999-4999-8999-999999999999@deadbeef" - }, - "fixedRefs": {} - }, - "writes": [] - } - }, - "expect": { - "status": 422, - "body": { - "reason": "missing-assets", - "missing": ["99999999-9999-4999-8999-999999999999@deadbeef"] - } - } - }, - { - "id": "delete-session", - "request": { "method": "DELETE", "path": "/api/preview-session/{previewId}" }, - "expect": { "status": 200, "body": { "success": true } } - }, - { - "id": "serve-after-delete-404", - "request": { "method": "GET", "path": "/preview/{previewId}/index.html" }, - "expect": { "status": 404, "headers": { "Cache-Control": "no-store" } } - } - ] -} diff --git a/tests/js/relay-host.test.ts b/tests/js/relay-host.test.ts index 07183e8..7ff0383 100644 --- a/tests/js/relay-host.test.ts +++ b/tests/js/relay-host.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { - attachMedia, clearOverlays, pingEmbeds, reflowOverlays, @@ -10,7 +9,6 @@ import { type WindowWithBridge = Window & { exeEmbedRelay?: unknown - exeMediaHost?: unknown } const w = window as WindowWithBridge @@ -18,7 +16,6 @@ const w = window as WindowWithBridge afterEach(() => { resetRelayForTests() delete w.exeEmbedRelay - delete w.exeMediaHost }) describe('relay-host', () => { @@ -56,19 +53,10 @@ describe('relay-host', () => { expect(postMessage).toHaveBeenCalledWith({ type: 'exe-embed', action: 'request' }, '*') }) - it('attachMedia attaches the media host to the iframe', () => { - const attach = vi.fn() - w.exeMediaHost = { attach } - const iframe = {} as HTMLIFrameElement - attachMedia(iframe) - expect(attach).toHaveBeenCalledWith(iframe, {}) - }) - - it('clear/reflow/attach are safe no-ops when the bridge is absent', () => { + it('clear/reflow are safe no-ops when the relay is absent', () => { expect(() => { clearOverlays() reflowOverlays() - attachMedia({} as HTMLIFrameElement) }).not.toThrow() }) }) From 872debb14df6de6e94deb553a8913d450e6871ce Mon Sep 17 00:00:00 2001 From: erseco Date: Sat, 25 Jul 2026 17:33:36 +0100 Subject: [PATCH 39/43] Port the preview API E2E to the snapshot contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The end-to-end check still drove the retired four-operation management API: it POSTed an empty body to create a session, then uploaded assets and published a revision. Against the snapshot endpoint that first call is a 400 ("Missing snapshot upload"), so the job failed. This is the one consumer the migration missed: a shell script, so it never turned up in the PHP and TypeScript greps used to find dangling references. Rewritten around the two real operations, keeping every security assertion it already made — CSRF 412, authless serving with the sandbox CSP and no allow-same-origin, the bare-root 302, owner scoping and 404 after delete — and adding three the snapshot contract makes worth checking: - replacing in place keeps the capability id and swaps the WHOLE tree, so a path the new archive omits stops resolving; - a rejected archive leaves the live snapshot untouched, so a bad upload can never destroy a preview that was working; - a non-owner may not replace someone else's capability, not just not delete it. Archives are built with python3 rather than a zip binary so the contents are exact and the script depends on nothing extra on the runner. --- tests/e2e/preview-api-e2e.sh | 107 +++++++++++++++++++++-------------- 1 file changed, 65 insertions(+), 42 deletions(-) diff --git a/tests/e2e/preview-api-e2e.sh b/tests/e2e/preview-api-e2e.sh index 2c6496a..602695a 100755 --- a/tests/e2e/preview-api-e2e.sh +++ b/tests/e2e/preview-api-e2e.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # -# API-level end-to-end test of the editor-preview serving contract v2 against a -# REAL running Nextcloud (no browser). +# API-level end-to-end test of the opaque editor-preview contract against a REAL +# running Nextcloud (no browser). # # Authentication is HTTP Basic auth: it authenticates per request via the # Authorization header and needs NO cookies, so it is reliable under the built-in @@ -16,10 +16,11 @@ # "CSRF check failed" — proving the route is NOT #[NoCSRFRequired]. That is # the real CSRF-enforcement assertion. # -# It asserts: CSRF enforcement (412), the create->asset->revision->serve -# round-trip, the authless opaque serving response (200 + sandbox CSP, no -# allow-same-origin), the bare-root 302, owner-scoping (403), dropped-part -# rejection (400) without advancing the revision, and 404 after delete. +# It asserts: CSRF enforcement (412), the publish->serve round-trip, the authless +# opaque serving response (200 + sandbox CSP, no allow-same-origin), the +# bare-root 302, replace-in-place, that a REJECTED archive leaves the live +# snapshot untouched, owner-scoping (403 on both publish and delete), and 404 +# after delete. # # Full editor-iframe browser E2E (external video / interactive-video) stays # blocked on a capable editor build and is documented as such, not faked here. @@ -52,6 +53,21 @@ fail() { echo "E2E FAIL: $*" >&2; exit 1; } api() { local u="$1" p="$2" m="$3" url="$4"; shift 4; curl -s -u "$u:$p" -H 'OCS-APIRequest: true' -X "$m" "$@" "$url"; } api_code() { local u="$1" p="$2" m="$3" url="$4"; shift 4; curl -s -o /dev/null -w '%{http_code}' -u "$u:$p" -H 'OCS-APIRequest: true' -X "$m" "$@" "$url"; } +# Build a snapshot ZIP. Args: then `name=contents` pairs. +# python3 is used rather than `zip` so the archive contents are exact and the +# script does not depend on a zip binary being installed on the runner. +make_zip() { + local out="$1"; shift + python3 - "$out" "$@" <<-'PY' + import sys, zipfile + out, *pairs = sys.argv[1:] + with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z: + for pair in pairs: + name, _, body = pair.partition("=") + z.writestr(name, body) + PY +} + # 0. Sanity — Basic auth actually authenticates user1 (so a later 412 is a real # CSRF rejection, not an auth failure hiding behind it). who="$(curl -s -u "$U1:$P1" -H 'OCS-APIRequest: true' "$ROOT/ocs/v2.php/cloud/user?format=json" | jq -r '.ocs.data.id // empty' 2>/dev/null || true)" @@ -60,37 +76,23 @@ echo " ok: Basic auth authenticates $U1" # 1. CSRF enforced — an AUTHENTICATED management POST WITHOUT the OCS-APIRequest # header (and no requesttoken) trips the CSRF middleware → 412. Proves the -# management route is not #[NoCSRFRequired]. The 412 happens in middleware, so -# no session is created. +# management route is not #[NoCSRFRequired]. The 412 happens in middleware, +# before the handler, so no snapshot upload is needed to prove it. csrf="$(curl -s -o "$TMP/csrf.txt" -w '%{http_code}' -u "$U1:$P1" -X POST "$MGMT")" [ "$csrf" = "412" ] || fail "CSRF proof: expected 412 without the CSRF-safe header, got $csrf (body: $(head -c 200 "$TMP/csrf.txt"))" grep -qi 'csrf' "$TMP/csrf.txt" || echo " note: 412 body did not explicitly mention CSRF: $(head -c 120 "$TMP/csrf.txt")" echo " ok: management enforces CSRF (412 for an authenticated request without the CSRF-safe header/token)" -# 2. Create → 201 { previewId, protocolVersion: 2 }. -resp="$(api "$U1" "$P1" POST "$MGMT")" +# 2. Publish a whole-project snapshot → { previewId }. +make_zip "$TMP/snapshot-v1.zip" \ + 'index.html=preview-v1' \ + 'content/photo.png=IMGBYTES!' +resp="$(api "$U1" "$P1" POST "$MGMT" -F "snapshot=@$TMP/snapshot-v1.zip")" pid="$(printf '%s' "$resp" | jq -r '.previewId // empty')" -[ -n "$pid" ] || fail "create returned no previewId: $resp" -[ "$(printf '%s' "$resp" | jq -r '.protocolVersion')" = "2" ] || fail "protocolVersion != 2: $resp" -echo " ok: created session $pid" - -# 3. Upload an asset (multipart: assets JSON + index-aligned files[]). -KEY="aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8" -printf 'IMGBYTES!' > "$TMP/asset.bin" # 9 bytes -api "$U1" "$P1" POST "$MGMT/$pid/assets" \ - -F "assets=[{\"key\":\"$KEY\",\"size\":9}]" \ - -F "files[]=@$TMP/asset.bin" >/dev/null -echo " ok: uploaded asset" +[ -n "$pid" ] || fail "publish returned no previewId: $resp" +echo " ok: published snapshot $pid" -# 4. Publish revision 1. -printf 'preview-v1' > "$TMP/index.html" -resp="$(api "$U1" "$P1" POST "$MGMT/$pid/revisions" \ - -F "revision={\"baseRevision\":0,\"nextRevision\":1,\"writes\":[\"index.html\"],\"deletes\":[],\"assetRefs\":{\"content/photo.png\":\"$KEY\"},\"fixedRefs\":{}}" \ - -F "files[]=@$TMP/index.html")" -[ "$(printf '%s' "$resp" | jq -r '.revision')" = "1" ] || fail "publish revision != 1: $resp" -echo " ok: published revision 1" - -# 5. Serve (authless, NO auth): 200 + opaque sandbox CSP + no allow-same-origin. +# 3. Serve (authless, NO auth): 200 + opaque sandbox CSP + no allow-same-origin. hdrs="$(curl -s -D - -o "$TMP/served.html" "$SERVE/$pid/index.html")" printf '%s' "$hdrs" | grep -q '^HTTP/[0-9.]* 200' || fail "serve not 200: $(printf '%s' "$hdrs" | head -1)" csp="$(printf '%s' "$hdrs" | grep -i '^content-security-policy:' || true)" @@ -98,27 +100,48 @@ printf '%s' "$csp" | grep -qi 'sandbox' || fail "serving CSP missing sandbox dir if printf '%s' "$csp" | grep -qi 'allow-same-origin'; then fail "serving CSP must NOT contain allow-same-origin: $csp" fi -grep -q 'preview-v1' "$TMP/served.html" || fail "served body missing revision content" +grep -q 'preview-v1' "$TMP/served.html" || fail "served body missing snapshot content" echo " ok: authless serve is 200 with opaque sandbox CSP (no allow-same-origin)" -# 6. Bare capability root → 302 (never inline index.html bytes). +# 3b. A non-scriptable file from the same snapshot is served too, and without the +# sandbox CSP (it is an asset tier, not a document). +hdrs="$(curl -s -D - -o "$TMP/served.png" "$SERVE/$pid/content/photo.png")" +printf '%s' "$hdrs" | grep -q '^HTTP/[0-9.]* 200' || fail "asset serve not 200" +grep -q 'IMGBYTES!' "$TMP/served.png" || fail "asset body mismatch" +echo " ok: snapshot assets serve from the same capability" + +# 4. Bare capability root → 302 (never inline index.html bytes). code="$(curl -s -o /dev/null -w '%{http_code}' "$SERVE/$pid")" [ "$code" = "302" ] || fail "bare root expected 302, got $code" echo " ok: bare root 302" -# 7. Owner-scoping — user2 on user1's session → 403. +# 5. Replace in place — same capability id, new bytes, and a path the new archive +# omits stops resolving (the whole tree is swapped, not merged). +make_zip "$TMP/snapshot-v2.zip" 'index.html=preview-v2' +resp="$(api "$U1" "$P1" POST "$MGMT" -F "snapshot=@$TMP/snapshot-v2.zip" -F "previewId=$pid")" +[ "$(printf '%s' "$resp" | jq -r '.previewId')" = "$pid" ] || fail "replace changed the capability id: $resp" +curl -s "$SERVE/$pid/index.html" | grep -q 'preview-v2' || fail "replace did not update the served bytes" +code="$(curl -s -o /dev/null -w '%{http_code}' "$SERVE/$pid/content/photo.png")" +[ "$code" = "404" ] || fail "a path the new snapshot omits should 404, got $code" +echo " ok: replace-in-place swaps the whole tree under the same capability" + +# 6. A REJECTED archive must leave the live snapshot untouched — a bad upload can +# never destroy a preview that was working. +make_zip "$TMP/snapshot-bad.zip" 'page.html=no index here' +code="$(api_code "$U1" "$P1" POST "$MGMT" -F "snapshot=@$TMP/snapshot-bad.zip" -F "previewId=$pid")" +[ "$code" = "400" ] || fail "archive without index.html expected 400, got $code" +curl -s "$SERVE/$pid/index.html" | grep -q 'preview-v2' || fail "a rejected upload clobbered the live snapshot" +echo " ok: rejected archive is 400 and leaves the live snapshot intact" + +# 7. Owner-scoping — user2 may neither replace nor delete user1's capability. +code="$(api_code "$U2" "$P2" POST "$MGMT" -F "snapshot=@$TMP/snapshot-v2.zip" -F "previewId=$pid")" +[ "$code" = "403" ] || fail "cross-user replace expected 403, got $code" code="$(api_code "$U2" "$P2" DELETE "$MGMT/$pid")" [ "$code" = "403" ] || fail "cross-user delete expected 403, got $code" -echo " ok: cross-user management is 403" - -# 8. Dropped multipart part — 1 write declared, 0 file parts → 400, revision unchanged. -code="$(api_code "$U1" "$P1" POST "$MGMT/$pid/revisions" \ - -F "revision={\"baseRevision\":1,\"nextRevision\":2,\"writes\":[\"index.html\"],\"deletes\":[],\"assetRefs\":{},\"fixedRefs\":{}}")" -[ "$code" = "400" ] || fail "dropped-part revision expected 400, got $code" -curl -s "$SERVE/$pid/index.html" | grep -q 'preview-v1' || fail "revision advanced despite dropped part" -echo " ok: dropped-part revision rejected (400), revision pointer unchanged" +curl -s "$SERVE/$pid/index.html" | grep -q 'preview-v2' || fail "cross-user attempt altered the snapshot" +echo " ok: cross-user management is 403 on both publish and delete" -# 9. Owner DELETE → 200, then serve → 404. +# 8. Owner DELETE → 200, then serve → 404. code="$(api_code "$U1" "$P1" DELETE "$MGMT/$pid")" [ "$code" = "200" ] || fail "owner delete expected 200, got $code" code="$(curl -s -o /dev/null -w '%{http_code}' "$SERVE/$pid/index.html")" From 6fe10f85c5bdd5bd1080a33750838969685cdf38 Mon Sep 17 00:00:00 2001 From: erseco Date: Sat, 25 Jul 2026 17:36:48 +0100 Subject: [PATCH 40/43] Report 403, not 404, when deleting another author's capability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The store's delete() collapses "does not exist" and "not yours" into a single false, so the controller answered 404 for both. Publishing over another author's capability answers 403 for the very same condition, which left the two halves of the management API disagreeing about what owner scoping looks like — and the serving contract documents 403/404. The distinction is drawn in the controller: 404 for an unknown capability, 403 for one owned by somebody else. Covered by the API E2E against a real Nextcloud, which asserts both the cross-user publish and the cross-user delete. --- lib/Controller/PreviewSessionController.php | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/Controller/PreviewSessionController.php b/lib/Controller/PreviewSessionController.php index 6984a31..88b1f07 100644 --- a/lib/Controller/PreviewSessionController.php +++ b/lib/Controller/PreviewSessionController.php @@ -78,16 +78,28 @@ public function create(): DataResponse { return new DataResponse(['previewId' => $result['previewId']], Http::STATUS_OK); } - /** DELETE {basePath}/api/preview-session/{previewId} */ + /** + * DELETE {basePath}/api/preview-session/{previewId} + * + * Owner scoping reports the same two statuses as the publish path: 404 for a + * capability that does not exist, 403 for one that belongs to somebody else. + * The store collapses both into a single `false`, so the distinction is drawn + * here — otherwise deleting another author's capability would answer 404 + * while publishing over it answers 403, for the very same condition. + */ #[NoAdminRequired] public function delete(string $previewId): DataResponse { $userId = $this->currentUserId(); if ($userId === null) { return $this->unauthenticated(); } - if (!$this->store->delete($previewId, $userId)) { + if (!$this->store->exists($previewId)) { return new DataResponse(['error' => 'Preview snapshot not found'], Http::STATUS_NOT_FOUND); } + if ($this->store->ownerOf($previewId) !== $userId) { + return new DataResponse(['error' => 'Access denied'], Http::STATUS_FORBIDDEN); + } + $this->store->delete($previewId, $userId); return new DataResponse([], Http::STATUS_OK); } From aab5c07a0df35595eadcc72446dbfee405fce275 Mon Sep 17 00:00:00 2001 From: erseco Date: Sat, 25 Jul 2026 19:44:13 +0100 Subject: [PATCH 41/43] refactor(preview): let the store own the ownership verdict, drop dead branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review pass over the snapshot migration. The 403-vs-404 fix landed one level too low. The store already produced a full authorization verdict for the publish path, so adding a thinner ownerOf() and rebuilding the verdict in the controller wrote the ownership rule twice — and the two verbs still disagreed on a malformed id (400 on publish, 404 on delete). guardExistingCapability() becomes the public authorize(), deleteOwned() runs through it, and the controller is now a verdict-to-JSON map on both verbs. exists() goes: it said nothing ownerOf() did not. PreviewServer carried three CSP branches that could never decide anything: resolve() labels a file a document exactly when its type is scriptable, so the two checks inside serveAsset() were unreachable and the one in serveBytes() was always true. They read as security policy while being dead. The kind now IS the scriptability — one field instead of two that could drift — and each tier states plainly why it does or does not carry the CSP. serveAsset's four near-identical header blocks collapse to one, which also stops the 304 from silently omitting Content-Type and Accept-Ranges. SnapshotArchive::extract() counted every byte it wrote and threw the total away, so the store walked the whole tree again to recount it on every publish. It now returns the number; treeBytes() is gone. Also deletes ASSET_KEY_RE, isValidAssetKey() and limits(), which described and validated a wire format the snapshot contract no longer has. --- lib/Controller/PreviewSessionController.php | 17 ++--- lib/Service/Preview/PreviewPolicy.php | 13 ---- lib/Service/Preview/PreviewServer.php | 56 ++++++-------- lib/Service/Preview/PreviewSnapshotStore.php | 75 ++++++++----------- lib/Service/Preview/SnapshotArchive.php | 5 +- .../Service/Preview/PreviewPolicyTest.php | 6 -- .../Service/Preview/PreviewServerTest.php | 2 +- .../Preview/PreviewSnapshotStoreTest.php | 15 +++- 8 files changed, 75 insertions(+), 114 deletions(-) diff --git a/lib/Controller/PreviewSessionController.php b/lib/Controller/PreviewSessionController.php index 88b1f07..c275ff9 100644 --- a/lib/Controller/PreviewSessionController.php +++ b/lib/Controller/PreviewSessionController.php @@ -81,11 +81,9 @@ public function create(): DataResponse { /** * DELETE {basePath}/api/preview-session/{previewId} * - * Owner scoping reports the same two statuses as the publish path: 404 for a - * capability that does not exist, 403 for one that belongs to somebody else. - * The store collapses both into a single `false`, so the distinction is drawn - * here — otherwise deleting another author's capability would answer 404 - * while publishing over it answers 403, for the very same condition. + * 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 { @@ -93,13 +91,10 @@ public function delete(string $previewId): DataResponse { if ($userId === null) { return $this->unauthenticated(); } - if (!$this->store->exists($previewId)) { - return new DataResponse(['error' => 'Preview snapshot not found'], Http::STATUS_NOT_FOUND); + $refused = $this->store->deleteOwned($previewId, $userId); + if ($refused !== null) { + return new DataResponse(['error' => $refused['error']], (int)$refused['status']); } - if ($this->store->ownerOf($previewId) !== $userId) { - return new DataResponse(['error' => 'Access denied'], Http::STATUS_FORBIDDEN); - } - $this->store->delete($previewId, $userId); return new DataResponse([], Http::STATUS_OK); } diff --git a/lib/Service/Preview/PreviewPolicy.php b/lib/Service/Preview/PreviewPolicy.php index 6dfbc43..394d6ab 100644 --- a/lib/Service/Preview/PreviewPolicy.php +++ b/lib/Service/Preview/PreviewPolicy.php @@ -63,14 +63,6 @@ final class PreviewPolicy { */ public const PREVIEW_ID_RE = '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/'; - /** - * Asset-key wire format: `{assetId}@{contentHashPrefix}` — a 36-char - * UUID-like id plus 8-64 hex chars of the content hash the project model - * already stores. The server validates the shape and treats the key as an - * opaque token; it NEVER hashes asset bytes. - */ - public const ASSET_KEY_RE = '/^[0-9a-fA-F-]{36}@[0-9a-f]{8,64}$/'; - /** * Scriptable document types that MUST carry the sandbox-first CSP so they * stay opaque even when opened top-level. Not just `text/html`: an @@ -147,11 +139,6 @@ public static function isValidPreviewId(string $previewId): bool { return preg_match(self::PREVIEW_ID_RE, $previewId) === 1; } - /** Whether $key is a well-formed asset key. */ - public static function isValidAssetKey(string $key): bool { - return preg_match(self::ASSET_KEY_RE, $key) === 1; - } - /** * True when the given MIME (with any `; charset=…` stripped) is a scriptable * document type and therefore needs the sandbox CSP. diff --git a/lib/Service/Preview/PreviewServer.php b/lib/Service/Preview/PreviewServer.php index ed56492..5b3d766 100644 --- a/lib/Service/Preview/PreviewServer.php +++ b/lib/Service/Preview/PreviewServer.php @@ -41,7 +41,7 @@ public function serve(string $previewId, string $rawPath, ?string $ifNoneMatch = return match ($file['kind']) { 'asset' => $this->serveAsset($file, $ifNoneMatch, $range), - default => $this->serveBytes($file, 'no-store'), + default => $this->serveDocument($file), }; } @@ -70,18 +70,18 @@ public function serveRoot(string $previewId): PreviewResponse { } /** - * A scriptable document: rewritten on every refresh, so `no-store`, and it - * carries the sandbox CSP. + * A scriptable document. It is rewritten on every refresh, so `no-store`, and + * it ALWAYS carries the sandbox CSP: `resolve()` only labels a file a + * document when its type is scriptable, so there is no case here that should + * go out without it. * - * @param array{contentType:string,isScriptable:bool,bytes?:string} $file + * @param array{contentType:string,bytes?:string} $file */ - private function serveBytes(array $file, string $cacheControl): PreviewResponse { + private function serveDocument(array $file): PreviewResponse { $headers = $this->baseHeaders(); $headers['Content-Type'] = $file['contentType']; - $headers['Cache-Control'] = $cacheControl; - if ($file['isScriptable']) { - $headers['Content-Security-Policy'] = PreviewPolicy::CSP; - } + $headers['Cache-Control'] = 'no-store'; + $headers['Content-Security-Policy'] = PreviewPolicy::CSP; return new PreviewResponse(200, $headers, $file['bytes'] ?? ''); } @@ -89,57 +89,43 @@ private function serveBytes(array $file, string $cacheControl): PreviewResponse * A non-scriptable asset: revalidated (`no-cache`) with an ETag, * `Accept-Ranges: bytes`, `If-None-Match` → 304 and single-range 206/416. * - * @param array{contentType:string,isScriptable:bool,filePath:string,size:int,etag:string} $file + * No sandbox CSP here, and that is not an omission: `resolve()` labels a file + * an asset precisely when its type is NOT scriptable, so a CSP branch on this + * path could never fire. + * + * @param array{contentType:string,filePath:string,size:int,etag:string} $file */ private function serveAsset(array $file, ?string $ifNoneMatch, ?string $range): PreviewResponse { $etag = '"' . $file['etag'] . '"'; + $size = $file['size']; + + $headers = $this->baseHeaders(); + $headers['Content-Type'] = $file['contentType']; + $headers['Cache-Control'] = 'no-cache'; + $headers['ETag'] = $etag; + $headers['Accept-Ranges'] = 'bytes'; if ($ifNoneMatch !== null && trim($ifNoneMatch) === $etag) { - $headers = $this->baseHeaders(); - $headers['Cache-Control'] = 'no-cache'; - $headers['ETag'] = $etag; return new PreviewResponse(304, $headers, ''); } - $size = $file['size']; $parsed = ($range !== null && trim($range) !== '') ? $this->parseRange($range, $size) : null; if ($parsed !== null && $parsed['satisfiable'] === false) { // A syntactically valid single range wholly outside the entity (e.g. // `bytes=99-` on a 10-byte body) is unsatisfiable → 416. A malformed, // multi-range or non-bytes header is IGNORED by parseRange (null) and // falls through to the normal 200 full body below. - $headers = $this->baseHeaders(); - $headers['Content-Type'] = $file['contentType']; - $headers['Cache-Control'] = 'no-cache'; - $headers['ETag'] = $etag; - $headers['Accept-Ranges'] = 'bytes'; $headers['Content-Range'] = 'bytes */' . $size; return new PreviewResponse(416, $headers, ''); } if ($parsed !== null) { [$start, $end] = [$parsed['start'], $parsed['end']]; $length = $end - $start + 1; - $headers = $this->baseHeaders(); - $headers['Content-Type'] = $file['contentType']; - $headers['Cache-Control'] = 'no-cache'; - $headers['ETag'] = $etag; - $headers['Accept-Ranges'] = 'bytes'; $headers['Content-Range'] = 'bytes ' . $start . '-' . $end . '/' . $size; $headers['Content-Length'] = (string)$length; - if ($file['isScriptable']) { - $headers['Content-Security-Policy'] = PreviewPolicy::CSP; - } return new PreviewResponse(206, $headers, $this->readSlice($file['filePath'], $start, $length)); } - $headers = $this->baseHeaders(); - $headers['Content-Type'] = $file['contentType']; - $headers['Cache-Control'] = 'no-cache'; - $headers['ETag'] = $etag; - $headers['Accept-Ranges'] = 'bytes'; - if ($file['isScriptable']) { - $headers['Content-Security-Policy'] = PreviewPolicy::CSP; - } return new PreviewResponse(200, $headers, (string)@file_get_contents($file['filePath'])); } diff --git a/lib/Service/Preview/PreviewSnapshotStore.php b/lib/Service/Preview/PreviewSnapshotStore.php index 54a6d35..0721d13 100644 --- a/lib/Service/Preview/PreviewSnapshotStore.php +++ b/lib/Service/Preview/PreviewSnapshotStore.php @@ -66,10 +66,6 @@ public function __construct( $this->clock = $clock ?? static fn (): int => time(); } - public function limits(): PreviewSnapshotLimits { - return $this->limits; - } - // ========================================================================= // Publication // ========================================================================= @@ -89,7 +85,7 @@ public function replace(string $ownerUserId, string $zipPath, ?string $previewId $replacing = $previewId !== null && $previewId !== ''; if ($replacing) { - $guard = $this->guardExistingCapability((string)$previewId, $ownerUserId); + $guard = $this->authorize((string)$previewId, $ownerUserId); if ($guard !== null) { return $guard; } @@ -126,11 +122,18 @@ public function replace(string $ownerUserId, string $zipPath, ?string $previewId } /** - * Refuse a replace whose capability is unknown or owned by somebody else. + * The authorization verdict for a capability this user is claiming. + * + * Both management verbs run through here, so publish and delete can never + * disagree about what owner scoping means: a malformed id is a 400, one + * nobody holds a 404 and somebody else's a 403. Keeping the rule in the + * store — rather than letting each caller assemble it from `exists()` and + * `ownerOf()` — is what makes that guarantee structural instead of a + * convention two controllers have to remember. * - * @return array{error:string,status:int}|null Null when the replace may proceed. + * @return array{error:string,status:int}|null Null when the caller may proceed. */ - private function guardExistingCapability(string $previewId, string $ownerUserId): ?array { + public function authorize(string $previewId, string $ownerUserId): ?array { if (!PreviewPolicy::isValidPreviewId($previewId)) { return ['error' => 'Invalid preview capability.', 'status' => 400]; } @@ -156,13 +159,13 @@ private function extractInto(string $zipPath, string $contentDir): array { } try { SnapshotArchive::inspect($zip, $this->limits); - SnapshotArchive::extract($zip, $contentDir, $this->limits); + $bytes = SnapshotArchive::extract($zip, $contentDir, $this->limits); } catch (RuntimeException $e) { return ['error' => $e->getMessage(), 'status' => 400]; } finally { $zip->close(); } - return ['bytes' => $this->treeBytes($contentDir)]; + return ['bytes' => $bytes]; } /** @@ -213,7 +216,12 @@ private function publish(string $staging, string $id): array { * Resolve a served path inside a snapshot and refresh its idle clock, so a * preview in use never expires under the author. * - * @return array{kind:string,contentType:string,isScriptable:bool,bytes?:string,filePath?:string,size?:int,etag?:string}|null + * A scriptable type is a `document` (rewritten on every refresh, so served + * whole and uncached); everything else is an `asset` (revalidated with an + * ETag and range-capable). The kind IS the scriptability — there is no second + * flag to disagree with it. + * + * @return array{kind:string,contentType:string,bytes?:string,filePath?:string,size?:int,etag?:string}|null */ public function resolve(string $previewId, string $rawPath): ?array { if (!PreviewPolicy::isValidPreviewId($previewId) || $this->isExpired($previewId)) { @@ -230,10 +238,7 @@ public function resolve(string $previewId, string $rawPath): ?array { $this->touch($previewId); $contentType = PreviewPolicy::mimeForPath($path); - $isScriptable = PreviewPolicy::isScriptable($contentType); - if ($isScriptable) { - // Scriptable documents are rewritten on every refresh, so they are - // served whole and uncached rather than revalidated. + if (PreviewPolicy::isScriptable($contentType)) { $bytes = @file_get_contents($file); if ($bytes === false) { return null; @@ -241,7 +246,6 @@ public function resolve(string $previewId, string $rawPath): ?array { return [ 'kind' => 'document', 'contentType' => $contentType, - 'isScriptable' => true, 'bytes' => $bytes, ]; } @@ -249,7 +253,6 @@ public function resolve(string $previewId, string $rawPath): ?array { return [ 'kind' => 'asset', 'contentType' => $contentType, - 'isScriptable' => false, 'filePath' => $file, 'size' => $size, 'etag' => sha1($path . '|' . (string)@filemtime($file) . '|' . $size), @@ -280,16 +283,6 @@ private function resolveInsideContent(string $previewId, string $path): ?string // Lifecycle // ========================================================================= - /** Whether the snapshot exists on disk (independent of TTL). */ - public function exists(string $previewId): bool { - if (!PreviewPolicy::isValidPreviewId($previewId)) { - return false; - } - $meta = $this->snapshotDir($previewId) . '/meta.json'; - clearstatcache(true, $meta); - return is_file($meta); - } - /** The owner user id of a snapshot, or null when it does not exist. */ public function ownerOf(string $previewId): ?string { $meta = $this->readMeta($previewId); @@ -297,13 +290,18 @@ public function ownerOf(string $previewId): ?string { return is_string($owner) ? $owner : null; } - /** Delete a snapshot owned by $ownerUserId. Returns whether it was removed. */ - public function delete(string $previewId, string $ownerUserId): bool { - if (!$this->exists($previewId) || $this->ownerOf($previewId) !== $ownerUserId) { - return false; + /** + * Delete a snapshot after {@see authorize()} has cleared the caller. + * + * @return array{error:string,status:int}|null Null once it is gone. + */ + public function deleteOwned(string $previewId, string $ownerUserId): ?array { + $guard = $this->authorize($previewId, $ownerUserId); + if ($guard !== null) { + return $guard; } $this->removeTree($this->snapshotDir($previewId)); - return true; + return null; } /** Whether a snapshot has been idle longer than the TTL. */ @@ -406,19 +404,6 @@ private function snapshotBytes(string $previewId): int { return isset($meta['bytes']) ? (int)$meta['bytes'] : 0; } - /** Total size of a freshly extracted tree. */ - private function treeBytes(string $dir): int { - $total = 0; - foreach (scandir($dir) ?: [] as $entry) { - if ($entry === '.' || $entry === '..') { - continue; - } - $path = $dir . '/' . $entry; - $total += is_dir($path) ? $this->treeBytes($path) : (int)@filesize($path); - } - return $total; - } - // ========================================================================= // Helpers // ========================================================================= diff --git a/lib/Service/Preview/SnapshotArchive.php b/lib/Service/Preview/SnapshotArchive.php index 07c8f5e..3752717 100644 --- a/lib/Service/Preview/SnapshotArchive.php +++ b/lib/Service/Preview/SnapshotArchive.php @@ -63,9 +63,11 @@ public static function inspect(ZipArchive $zip, PreviewSnapshotLimits $limits): * be enforced mid-file: an entry that inflates past the cap is abandoned * as soon as the cap is crossed, not after it has filled the disk. * + * @return int Bytes written — the caller needs the total and this already + * counted every one of them. * @throws RuntimeException When extraction fails or exceeds the budget. */ - public static function extract(ZipArchive $zip, string $targetDir, PreviewSnapshotLimits $limits): void { + public static function extract(ZipArchive $zip, string $targetDir, PreviewSnapshotLimits $limits): int { $written = 0; for ($index = 0; $index < $zip->numFiles; $index++) { $name = (string)$zip->getNameIndex($index); @@ -79,6 +81,7 @@ public static function extract(ZipArchive $zip, string $targetDir, PreviewSnapsh } $written += self::extractEntry($zip, $index, $destination, $limits->maxBytesPerSnapshot - $written); } + return $written; } /** diff --git a/tests/Unit/Service/Preview/PreviewPolicyTest.php b/tests/Unit/Service/Preview/PreviewPolicyTest.php index 2a33903..cea0843 100644 --- a/tests/Unit/Service/Preview/PreviewPolicyTest.php +++ b/tests/Unit/Service/Preview/PreviewPolicyTest.php @@ -146,10 +146,4 @@ public function testPreviewIdValidation(): void { self::assertFalse(PreviewPolicy::isValidPreviewId('../../etc/passwd')); } - public function testAssetKeyValidation(): void { - self::assertTrue(PreviewPolicy::isValidAssetKey('aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57')); - self::assertFalse(PreviewPolicy::isValidAssetKey('too-short@9c41')); - self::assertFalse(PreviewPolicy::isValidAssetKey('aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@NOTHEX!!')); - self::assertFalse(PreviewPolicy::isValidAssetKey('no-at-sign-1234567890123456789012')); - } } diff --git a/tests/Unit/Service/Preview/PreviewServerTest.php b/tests/Unit/Service/Preview/PreviewServerTest.php index 26fdc27..9a1125c 100644 --- a/tests/Unit/Service/Preview/PreviewServerTest.php +++ b/tests/Unit/Service/Preview/PreviewServerTest.php @@ -205,7 +205,7 @@ public function testInvalidPreviewIdIs404(): void { } public function testDeletedSnapshotStopsServing(): void { - $this->store->delete($this->previewId, 'alice'); + $this->store->deleteOwned($this->previewId, 'alice'); $response = $this->server()->serve($this->previewId, 'index.html'); self::assertSame(404, $response->status); self::assertSame('no-store', $response->headers['Cache-Control']); diff --git a/tests/Unit/Service/Preview/PreviewSnapshotStoreTest.php b/tests/Unit/Service/Preview/PreviewSnapshotStoreTest.php index 508cc0a..7e6696d 100644 --- a/tests/Unit/Service/Preview/PreviewSnapshotStoreTest.php +++ b/tests/Unit/Service/Preview/PreviewSnapshotStoreTest.php @@ -101,14 +101,25 @@ public function testReplaceRefusesAnUnknownCapability(): void { self::assertSame(404, $result['status']); } + /** + * Both verbs share one verdict, so an unknown capability and a malformed id + * report the same statuses here as they do on publish. + */ + public function testDeleteReportsTheSameVerdictAsPublish(): void { + $store = $this->store(); + + self::assertSame(404, $store->deleteOwned('ffffffff-ffff-4fff-bfff-ffffffffffff', 'alice')['status']); + self::assertSame(400, $store->deleteOwned('not-a-uuid', 'alice')['status']); + } + public function testDeleteIsOwnerScoped(): void { $store = $this->store(); $id = $store->replace('alice', $this->zip(['index.html' => 'ok']))['previewId']; - self::assertFalse($store->delete($id, 'mallory')); + self::assertSame(403, $store->deleteOwned($id, 'mallory')['status']); self::assertNotNull($store->resolve($id, 'index.html')); - self::assertTrue($store->delete($id, 'alice')); + self::assertNull($store->deleteOwned($id, 'alice')); self::assertNull($store->resolve($id, 'index.html')); } From dd2b49a71a03e85776ab518b696f55bf8d985c65 Mon Sep 17 00:00:00 2001 From: erseco Date: Sat, 25 Jul 2026 20:22:41 +0100 Subject: [PATCH 42/43] fix(preview): keep the asset ETag turning over on a same-size refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deriving the ETag from path, mtime and size traded a content hash for identity, but mtime has one-second granularity. An author who refreshes twice inside the same second with an edit that keeps a file the same length — a colour in a stylesheet — produced a byte-identical tag, so the browser was handed a 304 for the previous bytes and the change appeared not to take. The content directory's inode joins the tag: every publish extracts into a fresh directory and renames it in, so it always turns over. Where a filesystem does not report one it reads 0 and the tag degrades to the previous form, which is no worse. The regression test fails against that previous form. --- lib/Service/Preview/PreviewSnapshotStore.php | 9 +++++++- .../Service/Preview/PreviewServerTest.php | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/lib/Service/Preview/PreviewSnapshotStore.php b/lib/Service/Preview/PreviewSnapshotStore.php index 0721d13..45a4c9e 100644 --- a/lib/Service/Preview/PreviewSnapshotStore.php +++ b/lib/Service/Preview/PreviewSnapshotStore.php @@ -250,12 +250,19 @@ public function resolve(string $previewId, string $rawPath): ?array { ]; } $size = (int)@filesize($file); + // The content directory's inode is part of the identity on purpose: mtime + // has one-second granularity, so an author refreshing twice within the + // same second with an edit that keeps a file the same length would + // otherwise produce the same tag and be handed a 304 for the previous + // bytes. Every publish extracts into a fresh directory and renames it in, + // so the inode always turns over. + $generation = (string)@fileinode($this->snapshotDir($previewId) . '/content'); return [ 'kind' => 'asset', 'contentType' => $contentType, 'filePath' => $file, 'size' => $size, - 'etag' => sha1($path . '|' . (string)@filemtime($file) . '|' . $size), + 'etag' => sha1($path . '|' . $generation . '|' . (string)@filemtime($file) . '|' . $size), ]; } diff --git a/tests/Unit/Service/Preview/PreviewServerTest.php b/tests/Unit/Service/Preview/PreviewServerTest.php index 9a1125c..e80edc0 100644 --- a/tests/Unit/Service/Preview/PreviewServerTest.php +++ b/tests/Unit/Service/Preview/PreviewServerTest.php @@ -98,6 +98,29 @@ public function testConditionalRequestReturns304(): void { 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); From 5f9fb389b3935129b9c9b774a9f3b958eec14432 Mon Sep 17 00:00:00 2001 From: erseco Date: Wed, 29 Jul 2026 17:03:27 +0100 Subject: [PATCH 43/43] Replace hand-maintained embed mirrors with the vendored external-media bundle exe_embed_shim.js and exe_embed_relay.js were manual copies of eXeLearning core's external-media bridge that had to be kept in sync by hand. Vendor the built child/host bundle instead (src/embed/exe_external_media/), verified against a manifest with a pinned build hash so CI catches drift or tampering rather than relying on a human diff. EmbedShimSource centralizes reading the vendored shim so ContentController and the sandbox/CSP wiring (IframeSandbox playerFrameDomains, ViewController frame-src) go through one source of truth. Mirrors the whitelist in .distignore so the runtime bundle ships with the app package, same treatment as src/sw. --- .distignore | 8 +- .eslintrc.cjs | 9 +- .github/workflows/ci.yml | 13 + Makefile | 13 +- biome.json | 2 +- docs/preview-serving-contract.md | 4 +- docs/secure-iframe-viewer.md | 4 +- lib/Controller/ContentController.php | 15 +- lib/Controller/ViewController.php | 6 + lib/Service/EmbedShimInjector.php | 2 +- lib/Service/EmbedShimSource.php | 37 + lib/Service/IframeSandbox.php | 16 +- src/embed/exe_embed_relay.js | 640 ------------------ src/embed/exe_embed_shim.js | 354 ---------- .../exe-external-media-child.min.js | 13 + .../exe-external-media-host.min.js | 13 + .../exe-external-media.manifest.json | 18 + src/embed/exe_external_media/verify.mjs | 129 ++++ src/embed/relay-host.ts | 40 +- src/types/shims.d.ts | 4 +- src/viewer/ElpxViewer.vue | 6 +- .../Service/ExternalMediaArtifactTest.php | 62 ++ tests/Unit/Service/IframeSandboxTest.php | 26 + tests/js/exe-embed-relay.test.ts | 122 ---- tests/js/external-media-bundle.test.ts | 49 ++ tests/js/relay-host.test.ts | 39 ++ 26 files changed, 501 insertions(+), 1143 deletions(-) create mode 100644 lib/Service/EmbedShimSource.php delete mode 100644 src/embed/exe_embed_relay.js delete mode 100644 src/embed/exe_embed_shim.js create mode 100644 src/embed/exe_external_media/exe-external-media-child.min.js create mode 100644 src/embed/exe_external_media/exe-external-media-host.min.js create mode 100644 src/embed/exe_external_media/exe-external-media.manifest.json create mode 100644 src/embed/exe_external_media/verify.mjs create mode 100644 tests/Unit/Service/ExternalMediaArtifactTest.php delete mode 100644 tests/js/exe-embed-relay.test.ts create mode 100644 tests/js/external-media-bundle.test.ts 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 39a34a3..5305279 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -18,10 +18,11 @@ 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', + // 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 36b223d..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 diff --git a/Makefile b/Makefile index b4556b5..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 @@ -433,6 +438,12 @@ up: check-docker @docker exec -u www-data $(DOCKER_NAME) php occ config:system:set apps_paths 1 writable --value=true --type=boolean >/dev/null @echo ">> enabling exelearning" @docker exec -u www-data $(DOCKER_NAME) php occ app:enable exelearning + @# The first-run wizard opens a modal over the whole page on first login. In a dev + @# container that is pure noise, and it actively breaks automated checks: it covers + @# the content iframe, which the external-media host correctly reads as "obscured" + @# and hides every promoted player behind it. + @echo ">> disabling the first-run wizard" + @docker exec -u www-data $(DOCKER_NAME) php occ app:disable firstrunwizard >/dev/null 2>&1 || true @# `.elp(x)` → MIME mapping + icon alias. These are the only pieces @# a Nextcloud app cannot ship by itself: both files are read from @# /var/www/html/config/. See README → "Custom MIME types" for the diff --git a/biome.json b/biome.json index 2276ae5..09f3bef 100644 --- a/biome.json +++ b/biome.json @@ -17,7 +17,7 @@ "!**/js/**", "!**/exelearning/**", "!**/*.vue", - "!**/src/embed/exe_*.js" + "!**/src/embed/exe_external_media/**" ] }, "formatter": { diff --git a/docs/preview-serving-contract.md b/docs/preview-serving-contract.md index 0891974..3502d8b 100644 --- a/docs/preview-serving-contract.md +++ b/docs/preview-serving-contract.md @@ -149,9 +149,9 @@ reloads and injects a fresh one. ## External video inside the opaque iframe An opaque origin fails YouTube's and Vimeo's embedder check (Error 153), so the -players cannot run inside the sandbox. `exe_embed_shim.js` — inlined into the +players cannot run inside the sandbox. `exe-external-media-child.min.js` — inlined into the served package by `ContentController` — demotes provider iframes to geometry -placeholders, and `exe_embed_relay.js` on the trusted parent (driven by +placeholders, and `exe-external-media-host.min.js` on the trusted parent (driven by `src/embed/relay-host.ts`) overlays the real player over each one. ## Tests diff --git a/docs/secure-iframe-viewer.md b/docs/secure-iframe-viewer.md index 3eba4f0..07f6e86 100644 --- a/docs/secure-iframe-viewer.md +++ b/docs/secure-iframe-viewer.md @@ -25,14 +25,14 @@ the opaque path is served over real HTTP, not the SW. | Token minted at view-open (read permission checked here) | `lib/Controller/ViewController.php` | | Opaque iframe (`allow-scripts allow-popups allow-forms`) | `src/elpx/iframe-renderer.ts` | | Relay + media host in the parent page | `src/embed/relay-host.ts` + `src/viewer/ElpxViewer.vue` | -| eXe-core embed/media bridge mirrors | `src/embed/exe_embed_shim.js`, `exe_embed_relay.js`, `exe_media_policy.js`, `exe_media_host.js` | +| eXe-core external-media bundle (vendored) | `src/embed/exe_external_media/exe-external-media-child.min.js`, `exe-external-media-host.min.js` | Flow: the Viewer downloads + validates the package (legacy `.elp` → migration prompt), then loads the opaque iframe from `/content/{token}/index.html`. The PHP `ContentController` verifies the token, resolves the file, serves each entry with the sandbox CSP, and **inlines the embed shim** into HTML. Inside the opaque iframe the shim promotes each cross-origin/PDF sub-iframe to a geometry placeholder; the -parent's relay (`exe_embed_relay.js`) overlays a real player over it, and the media +parent's relay (`exe-external-media-host.min.js`) overlays a real player over it, and the media host (`exe_media_host.js`) drives the interactive-video iDevice. ## Capability model (cookieless) diff --git a/lib/Controller/ContentController.php b/lib/Controller/ContentController.php index c964b0a..4d7cc2a 100644 --- a/lib/Controller/ContentController.php +++ b/lib/Controller/ContentController.php @@ -7,6 +7,7 @@ use OCA\ExeLearning\Service\ContentTokenService; use OCA\ExeLearning\Service\ElpxPackageService; use OCA\ExeLearning\Service\EmbedShimInjector; +use OCA\ExeLearning\Service\EmbedShimSource; use OCA\ExeLearning\Service\IframeSandbox; use OCA\ExeLearning\Service\PackageMimeService; use OCA\ExeLearning\Service\ZipEntryService; @@ -97,17 +98,15 @@ public function serve(string $token, string $path = 'index.html'): DataDisplayRe } /** - * Inline shim source, or null when the mirror asset is not present. Read - * from src/embed/ at runtime, the same way {@see SwController} reads + * Inline shim source, or null when the vendored artifact is not present. Read from + * src/embed/ at runtime, the same way {@see SwController} reads * src/sw/exelearning-sw.js — the app ships its src/ tree. + * + * The bytes are the CANONICAL child bundle from eXeLearning core, not a copy kept + * here; {@see EmbedShimSource} owns that decision and is unit-tested for it. */ private function shimSource(): ?string { - $path = __DIR__ . '/../../src/embed/exe_embed_shim.js'; - if (!is_file($path)) { - return null; - } - $source = file_get_contents($path); - return $source === false ? null : $source; + return (new EmbedShimSource())->read(); } /** 404 with the mandatory hardening headers (present on every response). */ diff --git a/lib/Controller/ViewController.php b/lib/Controller/ViewController.php index 2d29d85..ec2f2f1 100644 --- a/lib/Controller/ViewController.php +++ b/lib/Controller/ViewController.php @@ -124,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/EmbedShimInjector.php b/lib/Service/EmbedShimInjector.php index 372f0bc..847c6e0 100644 --- a/lib/Service/EmbedShimInjector.php +++ b/lib/Service/EmbedShimInjector.php @@ -5,7 +5,7 @@ namespace OCA\ExeLearning\Service; /** - * Inlines the eXe-core external-media shim (exe_embed_shim.js) into a served + * Inlines the eXe-core external-media child (exe-external-media-child.min.js) into a served * HTML package document, so that inside the opaque iframe every cross-origin / * PDF sub-iframe is promoted to a geometry placeholder the parent relay can * overlay. Mirrors the inject-at-serve approach of wp-exelearning 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 index 0b38e3d..0936a23 100644 --- a/lib/Service/IframeSandbox.php +++ b/lib/Service/IframeSandbox.php @@ -31,7 +31,7 @@ class IframeSandbox { public const EMBED_STRICT = 'strict'; public const EMBED_OPEN = 'open'; - /** Hosts whose embedded players the relay may overlay (see exe_embed_relay.js). */ + /** Hosts whose embedded players the relay may overlay (see exe-external-media-host.min.js). */ private const PROVIDER_WHITELIST = [ 'www.youtube.com', 'youtube.com', @@ -147,4 +147,18 @@ public function permissionsPolicy(): 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/src/embed/exe_embed_relay.js b/src/embed/exe_embed_relay.js deleted file mode 100644 index 0aab2b1..0000000 --- a/src/embed/exe_embed_relay.js +++ /dev/null @@ -1,640 +0,0 @@ -/** - * Parent-side external-embed relay for the opaque-origin preview / secure package mode. - * - * Companion to exe_embed_shim.js (runs INSIDE the opaque iframe). In opaque mode the - * document is sandboxed, so cross-origin players (YouTube, Vimeo) and PDFs render blank. - * The shim replaces each candidate iframe with a placeholder and postMessages its - * geometry here; this relay (the trusted half, in the editor / host page) validates each - * URL and overlays the real player inline over the placeholder — automatically, with no - * click, tracking scroll/resize. - * - * Trust model: the promoted player is rendered cross-origin and SANDBOXED, so the - * same-origin policy isolates it from this host page (it cannot read the DOM, cookies, - * session or storage). Two modes: - * - 'open' (default): promote any iframe whose src is https AND cross-origin to the host - * (rejecting same-origin, sub/superdomains of the host, IP/loopback/local hosts and - * userinfo). No host list. The host is irrelevant to escape; the residual is - * phishing/tracking, bounded to the content's own box (the overlay is clamped). - * - 'strict': only a maintained host allowlist with per-provider canonical-URL - * reconstruction, for high-security deployments. - * "Any https .pdf" is always allowed (same-origin only for the package's own files). - * - * Messages are authenticated by window identity (event.source === a known CONTENT - * iframe, never a promoted player); the opaque origin has no useful event.origin. - * - * Exposed two ways from a single body: window.exeEmbedRelay (browser bootstrap) and - * module.exports (tests). - * - * CANONICAL SOURCE for the eXeLearning embedder family lives here in eXeLearning core - * (public/app/common/exe_embed_bridge/exe_embed_relay.js). The host plugins - * (mod_exelearning, wp-exelearning, omeka-s-exelearning, procomun) mirror this logic - * (only the export wrapper differs). Keep them in sync; changes flow from core outward. - */ -(function () { - 'use strict'; - - /** - * Build a host lookup map from a whitelist array (lowercased). Used by 'strict' mode. - * - * @param {string[]} list - * @returns {Object} - */ - function buildWhitelist(list) { - var map = {}; - (list || []).forEach(function (host) { - map[String(host).toLowerCase()] = true; - }); - return map; - } - - /** - * Directory portion of the content iframe src (everything up to the last '/'). - * - * @param {string} src - * @returns {string} - */ - function contentDir(src) { - try { - return new URL(src, window.location.href).href.replace(/[^/]*([?#].*)?$/, ''); - } catch (e) { - return ''; - } - } - - /** - * Long hex token shared by the content URL and its assets (null when there is - * none, e.g. content URLs that use numeric ids). - * - * @param {string} src - * @returns {?string} - */ - function packageId(src) { - var match = String(src).match(/[a-f0-9]{12,}/i); - return match ? match[0] : null; - } - - /** - * Whether a same-origin URL is one of this package's own extracted files: under - * the content's own directory, or carrying the package hash as a path segment. - * - * @param {URL} url - * @param {string} contentSrc - * @returns {boolean} - */ - function isSameOriginPackageFile(url, contentSrc) { - var dir = contentDir(contentSrc); - if (dir && url.href.indexOf(dir) === 0) { - return true; - } - var id = packageId(contentSrc); - return !!(id && url.pathname.indexOf('/' + id + '/') !== -1); - } - - /** - * Whether a host is an IP literal (v4/v6) or a loopback/local name. Such hosts are - * cross-origin to the LMS yet target the machine/internal network, so they are - * rejected even though SOP would isolate them. - * - * @param {string} host Lowercased URL.hostname. - * @returns {boolean} - */ - function isIpOrLocalHost(host) { - if (!host) { return true; } - if (host === 'localhost' || /\.localhost$/.test(host) || /\.local$/.test(host)) { return true; } - if (host.charAt(0) === '[' || host.indexOf(':') !== -1) { return true; } // IPv6 (bracketed). - if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) { return true; } // Any IPv4 literal. - return false; - } - - /** - * Lowercase a hostname and strip a single trailing dot. 'lms.example.org.' (the - * FQDN-root form) resolves to the same vhost as 'lms.example.org' but compares - * unequal as a raw string, so without this it would slip past the same-origin / - * related-to-LMS gate below and be promoted as a cross-origin player. - * - * @param {string} host - * @returns {string} - */ - function normalizeHost(host) { - return (host || '').toLowerCase().replace(/\.$/, ''); - } - - /** - * Whether a host equals, is a subdomain of, or is a superdomain of the LMS host - * (dotted boundary so 'evil-lms.example' does not match 'lms.example'). Such hosts - * may share the LMS cookies, so they are rejected. Both sides are normalised so the - * trailing-dot FQDN-root form cannot evade the comparison. - * - * @param {string} host - * @param {string} lmsHost - * @returns {boolean} - */ - function isRelatedToLms(host, lmsHost) { - host = normalizeHost(host); - lmsHost = normalizeHost(lmsHost); - if (!lmsHost) { return false; } - return host === lmsHost || host.endsWith('.' + lmsHost) || lmsHost.endsWith('.' + host); - } - - /** - * The structural invariant: an https URL cross-origin to the LMS and not pointing at - * a sub/superdomain, an IP/loopback/local host, or carrying userinfo. This is the - * only attacker-influenced gate in 'open' mode, and it is what makes the sandboxed - * player's allow-same-origin safe (the embed keeps ITS OWN origin, isolated by SOP). - * - * @param {URL} url - * @returns {boolean} - */ - function isCrossOriginHttps(url) { - if (url.protocol !== 'https:') { return false; } - if (url.username || url.password) { return false; } - if (url.origin === window.location.origin) { return false; } - var host = normalizeHost(url.hostname); - if (isIpOrLocalHost(host)) { return false; } - var lmshost = (window.location && window.location.hostname) ? window.location.hostname : ''; - if (isRelatedToLms(host, lmshost)) { return false; } - return true; - } - - // Provider templates for the id-only channel (DEC-0067): the parent rebuilds the canonical - // embed URL from {provider, objectId} reported by the shim, re-checking the object id - // against a strict per-provider regex so it cannot carry a path/query/fragment and escape - // the template (e.g. '../../x' or 'a/b?c'). The reconstructed URL still runs through - // validate() (structural invariant / strict whitelist), so this narrows the surface for - // recognised providers; it is not, by itself, the trust gate. - var PROVIDER_TEMPLATES = { - youtube: { re: /^[A-Za-z0-9_-]{6,}$/, build: function (id) { return 'https://www.youtube-nocookie.com/embed/' + id; } }, - vimeo: { re: /^[0-9]+$/, build: function (id) { return 'https://player.vimeo.com/video/' + id; } }, - dailymotion: { re: /^[A-Za-z0-9]{5,}$/, build: function (id) { return 'https://www.dailymotion.com/embed/video/' + id; } }, - 'mediateca-madrid': { re: /^[A-Za-z0-9]{8,}$/, build: function (id) { return 'https://mediateca.educa.madrid.org/video/' + id + '/fs'; } } - }; - - /** - * Rebuild the canonical embed URL for a recognised provider from its object id, or null - * if the provider is unknown or the id is not the exact shape the template expects. - * - * @param {string} provider - * @param {string} objectId - * @returns {?string} - */ - function reconstructProvider(provider, objectId) { - var t = PROVIDER_TEMPLATES[provider]; - if (!t || typeof objectId !== 'string' || !t.re.test(objectId)) { - return null; - } - return t.build(objectId); - } - - /** - * Validate an embed URL. Returns {url, kind ('video'|'pdf'), sameorigin?} or null. - * - * @param {string} raw The reported (absolute) embed URL. - * @param {string} contentSrc The src of the content iframe that reported it. - * @param {Object} opts {strict: boolean, whitelist: Object}. - * @returns {?Object} - */ - function validate(raw, contentSrc, opts) { - opts = opts || {}; - var url; - try { - // Parse as an ABSOLUTE URL (the shim always reports absolute). No base: - // a relative/scheme-relative value would otherwise inherit the LMS origin - // and pass as same-origin -- here it throws and is rejected instead. - url = new URL(raw); - } catch (e) { - return null; - } - if (url.username || url.password) { - return null; // Reject userinfo, e.g. https://evil.com@youtube.com/. - } - var host = url.hostname.toLowerCase(); - - // PDFs: any cross-origin https .pdf, or a same-origin file that belongs to this - // package (served as application/pdf + nosniff, never executable HTML). - if (/\.pdf$/i.test(url.pathname)) { - if (url.origin === window.location.origin) { - return isSameOriginPackageFile(url, contentSrc) ? { url: url.href, kind: 'pdf', sameorigin: true } : null; - } - return isCrossOriginHttps(url) ? { url: url.href, kind: 'pdf' } : null; - } - - // Strict mode: maintained host allowlist + per-provider canonical reconstruction. - if (opts.strict) { - var whitelist = opts.whitelist || {}; - if (whitelist[host] && url.protocol === 'https:') { - var m; - if (host.indexOf('youtube') !== -1) { - m = url.pathname.match(/^\/embed\/([A-Za-z0-9_-]{6,})$/); - return m ? { url: 'https://www.youtube-nocookie.com/embed/' + m[1], kind: 'video' } : null; - } - if (host.indexOf('vimeo') !== -1) { - m = url.pathname.match(/^\/video\/([0-9]+)$/); - return m ? { url: 'https://player.vimeo.com/video/' + m[1], kind: 'video' } : null; - } - if (host.indexOf('dailymotion') !== -1) { - m = url.pathname.match(/^\/embed\/video\/([A-Za-z0-9]{5,})$/); - return m ? { url: 'https://www.dailymotion.com/embed/video/' + m[1], kind: 'video' } : null; - } - if (host === 'mediateca.educa.madrid.org') { - m = url.pathname.match(/^\/video\/([A-Za-z0-9]{8,})(?:\/fs)?$/); - return m ? { url: 'https://mediateca.educa.madrid.org/video/' + m[1] + '/fs', kind: 'video' } : null; - } - } - return null; - } - - // Open mode (default): any cross-origin https iframe is a video embed. - return isCrossOriginHttps(url) ? { url: url.href, kind: 'video' } : null; - } - - /** - * Create the player iframe for a validated embed. The video player gets allow-same-origin - * (so the cross-origin provider keeps its own origin and renders) while omitting - * allow-top-navigation/allow-modals, so a hostile embed cannot redirect the LMS tab or - * spam dialogs. A same-origin package PDF is served as application/pdf + nosniff (never - * executable HTML) and is left unsandboxed so the browser's built-in viewer renders it; a - * CROSS-ORIGIN PDF URL comes from the untrusted package, so it is sandboxed WITHOUT - * allow-top-navigation (a server can serve scripted HTML at a .pdf path, which unsandboxed - * could top-navigate the parent tab to a phishing page). - * - * @param {Object} result {url, kind, sameorigin?} from validate(). - * @returns {HTMLIFrameElement} - */ - function makePlayer(result) { - var frame = document.createElement('iframe'); - frame.style.cssText = 'position:absolute;border:0;pointer-events:auto;'; - // Mark as a player so it is never mistaken for a content source (message auth). - frame.setAttribute('data-exe-embed-player', '1'); - if (result.kind === 'video') { - frame.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-popups allow-forms allow-presentation'); - frame.setAttribute('allow', 'autoplay; encrypted-media; fullscreen; picture-in-picture; clipboard-write'); - frame.setAttribute('allowfullscreen', ''); - frame.setAttribute('referrerpolicy', 'strict-origin-when-cross-origin'); - } else if (result.sameorigin) { - // Same-origin PDF that belongs to THIS package: served application/pdf + nosniff - // (never executable HTML, so it cannot script or navigate), left unsandboxed so the - // browser's built-in viewer renders it (it shows the broken-document icon inside a - // sandbox). The load guard still removes it if it redirects to the LMS origin. - frame.setAttribute('allow', 'fullscreen'); - frame.setAttribute('referrerpolicy', 'no-referrer'); - } else { - // Cross-origin PDF whose URL is controlled by the untrusted package. A server can - // serve scripted HTML at a ".pdf" path; unsandboxed, that frame could top-navigate - // the Moodle tab to a phishing page on a click (a package must never change the - // parent URL). Sandbox it WITHOUT allow-top-navigation/allow-scripts; allow-same- - // origin keeps the provider's own origin (SOP-isolated from the LMS). Trade-off: a - // genuine cross-origin PDF may render the broken-document icon under the sandbox -- - // accepted, since local package PDFs (the common case) take the branch above and - // blocking the tab-redirect vector matters more than inlining a remote PDF. - frame.setAttribute('sandbox', 'allow-same-origin'); - frame.setAttribute('allow', 'fullscreen'); - frame.setAttribute('referrerpolicy', 'no-referrer'); - } - frame.src = result.url; - // Tag with the URL it renders so sync() can detect when a reused embed id (the - // shim restarts its counter per page) now points at a different URL. - frame.setAttribute('data-exe-embed-src', result.url); - return frame; - } - - /** - * Create a relay instance. - * - * @param {Object} config {mode: 'open'|'strict', whitelist: string[]} - * @returns {Object} - */ - function createRelay(config) { - config = config || {}; - var strict = config.mode === 'strict'; - var whitelist = buildWhitelist(config.whitelist); - var overlays = []; - // Handles for the listeners/timer init() installs, so dispose() can remove - // them and init() can stay idempotent (a second init() must not stack a - // second drift interval + duplicate window listeners on the same relay). - var driftTimer = null; - var started = false; - - function findOverlay(iframe) { - for (var i = 0; i < overlays.length; i++) { - if (overlays[i].iframe === iframe) { - return overlays[i]; - } - } - return null; - } - - // Resolve the CONTENT iframe a message came from. Promoted players are excluded - // (data-exe-embed-player): a sandboxed player with allow-same-origin could - // otherwise postMessage a forged 'sync' and impersonate a content source. - function frameForSource(source) { - var frames = document.getElementsByTagName('iframe'); - for (var i = 0; i < frames.length; i++) { - if (frames[i].getAttribute('data-exe-embed-player')) { - continue; - } - if (frames[i].contentWindow === source) { - return frames[i]; - } - } - return null; - } - - function overlayFor(iframe) { - var entry = findOverlay(iframe); - if (entry) { - return entry; - } - var el = document.createElement('div'); - el.className = 'exe-embed-overlay'; - el.style.cssText = 'position:absolute;overflow:hidden;pointer-events:none;z-index:2147483646;'; - document.body.appendChild(el); - entry = { iframe: iframe, el: el, players: {} }; - overlays.push(entry); - return entry; - } - - function positionOverlay(entry, rect) { - rect = rect || entry.iframe.getBoundingClientRect(); - var scrollX = window.pageXOffset || document.documentElement.scrollLeft || 0; - var scrollY = window.pageYOffset || document.documentElement.scrollTop || 0; - entry.el.style.left = (rect.left + scrollX) + 'px'; - entry.el.style.top = (rect.top + scrollY) + 'px'; - entry.el.style.width = rect.width + 'px'; - entry.el.style.height = rect.height + 'px'; - // Remembered so checkDrift() can detect host-driven moves of the content - // iframe (panel toggles, sidebar show/hide) that fire no scroll/resize. - entry.lastRect = { left: rect.left, top: rect.top, width: rect.width, height: rect.height }; - } - - /** - * Re-position any overlay whose content iframe box moved since it was last - * placed. The host page can move or resize the content iframe without any - * scroll/resize event firing (editor sidebar toggles, panel slide-ins, CSS - * transforms), which would strand the overlay at its old position. Called - * from a low-frequency interval in init(); one getBoundingClientRect per - * overlay per tick. Returns how many overlays were re-positioned. - * - * @returns {number} - */ - function checkDrift() { - var moved = 0; - for (var i = 0; i < overlays.length; i++) { - var entry = overlays[i]; - var rect = entry.iframe.getBoundingClientRect(); - var last = entry.lastRect; - if ( - !last || - rect.left !== last.left || - rect.top !== last.top || - rect.width !== last.width || - rect.height !== last.height - ) { - positionOverlay(entry, rect); - moved += 1; - } - } - return moved; - } - - // D1: if a promoted embed lands SAME-ORIGIN to the LMS (e.g. a cross-origin URL - // that 30x-redirects to this origin), with allow-same-origin it would become - // scriptable against this page -> remove it. A genuine cross-origin player throws - // on contentWindow.document (expected, kept). Not armed for same-origin package - // PDFs (intentionally same-origin, served as application/pdf). - function armSameOriginGuard(entry, id, player) { - player.addEventListener('load', function () { - try { - if (player.contentWindow && player.contentWindow.document) { - if (player.parentNode) { player.parentNode.removeChild(player); } - if (entry.players[id] === player) { delete entry.players[id]; } - } - } catch (e) { /* cross-origin: expected, keep the player */ } - }); - } - - function sync(entry, embeds, contentSrc) { - // The content iframe's box is invariant across this sync pass (the loop only - // mutates the overlay and its players), so read it once and reuse it for the - // overlay position and every player clamp -- avoids a forced reflow per embed. - var rect = entry.iframe.getBoundingClientRect(); - positionOverlay(entry, rect); - var seen = {}; - embeds.forEach(function (embed) { - if (!embed || typeof embed.id !== 'string') { - return; - } - if (!isFinite(embed.x) || !isFinite(embed.y) || !isFinite(embed.w) || !isFinite(embed.h)) { - return; - } - // id-only channel (DEC-0067): for recognised providers the shim reports - // {provider, objectId} and the parent rebuilds the canonical URL from a - // fixed template (the author URL never crosses for these). Unknown embeds - // keep the URL path. Either way validate() runs the structural invariant. - var raw = embed.url; - if (embed.provider && embed.objectId) { - raw = reconstructProvider(embed.provider, embed.objectId); - if (!raw) { - return; - } - } - var result = validate(raw, contentSrc, { strict: strict, whitelist: whitelist }); - if (!result) { - return; - } - seen[embed.id] = true; - var player = entry.players[embed.id]; - // After the content navigates, the shim reuses ids (exe-embed-1, ...) for - // the new page's embeds. If this id now renders a different URL, drop the - // stale player so the previous page's video does not linger here. - if (player && player.getAttribute('data-exe-embed-src') !== result.url) { - player.parentNode.removeChild(player); - delete entry.players[embed.id]; - player = null; - } - if (!player) { - player = makePlayer(result); - entry.el.appendChild(player); - entry.players[embed.id] = player; - if (!result.sameorigin) { - armSameOriginGuard(entry, embed.id, player); - } - } - // Defence in depth against clickjacking: the overlay is clamped to the - // content iframe's box and clips with overflow:hidden, so a player can - // never cover host UI outside the iframe. Cap the player size to the - // overlay too (the content reports geometry, the parent owns rendering). - // Reuses the iframe rect read once at the top of this pass. - player.style.left = embed.x + 'px'; - player.style.top = embed.y + 'px'; - player.style.width = Math.min(embed.w, rect.width) + 'px'; - player.style.height = Math.min(embed.h, rect.height) + 'px'; - }); - Object.keys(entry.players).forEach(function (id) { - if (!seen[id]) { - entry.players[id].parentNode.removeChild(entry.players[id]); - delete entry.players[id]; - } - }); - } - - function onMessage(event) { - var data = event.data; - if (!data || data.type !== 'exe-embed' || data.action !== 'sync' || !Array.isArray(data.embeds)) { - return; - } - var iframe = frameForSource(event.source); - if (!iframe) { - return; - } - sync(overlayFor(iframe), data.embeds, iframe.src); - } - - // Browser-only glue below (window listeners, reflow on scroll/resize, pinging - // the content iframes). Exercised by the Playwright/Firefox e2e - // (tests/e2e/embed.spec.cjs), not the happy-dom unit tests. - /* v8 ignore start */ - function pingAll() { - var frames = document.getElementsByTagName('iframe'); - for (var i = 0; i < frames.length; i++) { - if (frames[i].getAttribute('data-exe-embed-player')) { - continue; - } - try { - frames[i].contentWindow.postMessage({ type: 'exe-embed', action: 'request' }, '*'); - } catch (e) { - // Cross-origin player iframes reject this; harmless. - } - } - } - - var scheduled = false; - function scheduleReflow() { - if (scheduled) { - return; - } - scheduled = true; - window.requestAnimationFrame(function () { - scheduled = false; - for (var i = 0; i < overlays.length; i++) { - positionOverlay(overlays[i]); - } - }); - } - /* v8 ignore stop */ - - // Tear down every overlay and its players. Used when the host (e.g. the editor - // preview panel) hides or closes the content iframe: the overlay lives on the - // host's own body, so without this it would linger over the host UI. A later - // sync (after the iframe reloads) rebuilds the overlays cleanly. - function clear() { - for (var i = 0; i < overlays.length; i++) { - var entry = overlays[i]; - var ids = Object.keys(entry.players); - for (var j = 0; j < ids.length; j++) { - var player = entry.players[ids[j]]; - if (player && player.parentNode) { - player.parentNode.removeChild(player); - } - } - entry.players = {}; - if (entry.el && entry.el.parentNode) { - entry.el.parentNode.removeChild(entry.el); - } - } - overlays.length = 0; - } - - // Re-place every overlay over its content iframe's CURRENT box. The host (e.g. the - // editor preview panel) can move the iframe with a CSS transform (slide-in), which - // fires no scroll/resize, so an overlay placed during the animation would stay at - // its sync-time position. The host calls this once the move settles. - function reflow() { - for (var i = 0; i < overlays.length; i++) { - positionOverlay(overlays[i]); - } - } - - // Tear down clear() the overlays AND remove the window listeners + drift - // timer that init() installed, so a relay whose host is gone (preview panel - // disposed, tab closed) leaves nothing running. Idempotent; init() can run - // again afterwards on a reused relay. - function dispose() { - clear(); - /* v8 ignore start */ - if (driftTimer !== null) { - window.clearInterval(driftTimer); - driftTimer = null; - } - window.removeEventListener('message', onMessage); - window.removeEventListener('resize', scheduleReflow); - window.removeEventListener('scroll', scheduleReflow, true); - window.removeEventListener('load', pingAll); - /* v8 ignore stop */ - started = false; - } - - return { - onMessage: onMessage, - sync: sync, - clear: clear, - reflow: reflow, - checkDrift: checkDrift, - dispose: dispose, - validate: function (raw, contentSrc) { - return validate(raw, contentSrc, { strict: strict, whitelist: whitelist }); - }, - /* v8 ignore start */ - init: function () { - // Idempotent: a second init() on the same relay must not stack a - // second drift interval and duplicate window listeners. - if (started) { - return this; - } - started = true; - window.addEventListener('message', onMessage); - window.addEventListener('resize', scheduleReflow); - window.addEventListener('scroll', scheduleReflow, true); - window.addEventListener('load', pingAll); - pingAll(); - window.setTimeout(pingAll, 500); - // Host layout changes (sidebar toggles, panel slide-ins) move the - // content iframe with no scroll/resize event; keep the overlays - // pinned to it with a cheap low-frequency drift check. - driftTimer = window.setInterval(checkDrift, 300); - return this; - } - /* v8 ignore stop */ - }; - } - - /** - * Bootstrap: create a relay from config and start listening. - * - * @param {Object} config {mode: 'open'|'strict', whitelist: string[]} - * @returns {Object} - */ - /* v8 ignore next 3 */ - function init(config) { - return createRelay(config).init(); - } - - var exp = { - buildWhitelist: buildWhitelist, - contentDir: contentDir, - packageId: packageId, - isSameOriginPackageFile: isSameOriginPackageFile, - isIpOrLocalHost: isIpOrLocalHost, - normalizeHost: normalizeHost, - isRelatedToLms: isRelatedToLms, - isCrossOriginHttps: isCrossOriginHttps, - reconstructProvider: reconstructProvider, - validate: validate, - makePlayer: makePlayer, - createRelay: createRelay, - init: init - }; - // Test runner (Vitest/Node) consumes module.exports. - if (typeof module !== 'undefined' && module.exports) { module.exports = exp; } - // Browser bootstrap (view.php) consumes window.exeEmbedRelay. - if (typeof window !== 'undefined') { window.exeEmbedRelay = exp; } -})(); diff --git a/src/embed/exe_embed_shim.js b/src/embed/exe_embed_shim.js deleted file mode 100644 index bb2d9e9..0000000 --- a/src/embed/exe_embed_shim.js +++ /dev/null @@ -1,354 +0,0 @@ -/** - * In-iframe external-embed shim for the opaque-origin preview / secure package mode. - * - * Loaded from the of every rendered page. In opaque-origin mode the document - * runs in a sandbox without allow-same-origin, so the sandbox flag propagates to nested - * iframes and cross-origin players (YouTube, Vimeo) plus PDFs render blank. This shim - * replaces each cross-origin (https) or .pdf iframe with a same-size placeholder and - * reports its geometry to the parent, which validates it and overlays the real player - * inline (see exe_embed_relay.js). It self-activates ONLY in the opaque origin, so the - * same file stays dormant in same-origin rendering (where external players already - * render inline and the relay is not loaded). - * - * There is no host list here: the shim promotes any cross-origin https (or .pdf) iframe - * as a candidate and the parent relay is the authoritative gate (open vs strict mode). - * postMessage targetOrigin is '*' because the opaque origin has no stable value; the - * parent authenticates messages by event.source instead. - * - * Exposed two ways from a single body: window.exeEmbedShim (browser bootstrap) and - * module.exports (tests). - * - * CANONICAL SOURCE for the eXeLearning embedder family lives here in eXeLearning core - * (public/app/common/exe_embed_bridge/exe_embed_shim.js). The host plugins - * (mod_exelearning, wp-exelearning, omeka-s-exelearning, procomun) mirror this logic - * (only the export wrapper differs). Keep them in sync; changes flow from core outward. - */ -(function () { - 'use strict'; - - /** - * Whether this document runs in an opaque origin (secure sandbox). In an opaque - * origin document.cookie throws and window.origin is "null". - * - * @returns {boolean} - */ - function isOpaqueOrigin() { - try { - void document.cookie; - return window.origin === 'null'; - } catch (e) { - return true; - } - } - - /** - * Whether a URL path ends in .pdf (PDFs also fail under the opaque sandbox). - * - * @param {string} url - * @returns {boolean} - */ - function isPdfUrl(url) { - try { - return /\.pdf$/i.test(new URL(url, window.location.href).pathname); - } catch (e) { - return false; - } - } - - /** - * Whether a src resolves to an https URL on a host other than this document's own - * (served) host -- i.e. a cross-origin external embed. The opaque document is still - * served from the platform, so window.location.hostname is the platform host and the - * comparison is reliable. The parent relay re-validates authoritatively (DEC-0061); - * this is only a candidate filter so same-origin content iframes are left untouched. - * - * @param {string} src - * @returns {boolean} - */ - function isCrossOriginHttps(src) { - try { - var u = new URL(src, window.location.href); - // Strip a single trailing dot so the LMS host in its FQDN-root form - // ('host.') counts as same-host and is not reported as a candidate. - var host = u.hostname.toLowerCase().replace(/\.$/, ''); - var here = window.location.hostname.toLowerCase().replace(/\.$/, ''); - return u.protocol === 'https:' && host !== here; - } catch (e) { - return false; - } - } - - /** - * Whether an iframe src should be promoted to the parent: any cross-origin https - * embed or a .pdf (both render blank under the opaque sandbox). No host list -- the - * parent relay decides what actually renders (open vs strict mode). - * - * @param {string} src - * @returns {boolean} - */ - function isPromotable(src) { - return isCrossOriginHttps(src) || isPdfUrl(src); - } - - /** - * Recognise a known video provider from an embed src and extract its object id, so the - * shim can report {provider, objectId} instead of the author URL (DEC-0067 id-only - * channel). The parent rebuilds the canonical URL from a fixed template; this avoids - * passing the author's URL across the boundary for recognised providers. Returns null - * for unknown hosts or unexpected paths (the caller then falls back to URL mode). The - * id shape is intentionally permissive here; the parent re-checks it against a strict - * regex before templating it. - * - * @param {string} src - * @returns {?{provider: string, objectId: string}} - */ - function extractProvider(src) { - var u; - try { - u = new URL(src, window.location.href); - } catch (e) { - return null; - } - if (u.protocol !== 'https:') { - return null; - } - var host = u.hostname.toLowerCase().replace(/\.$/, ''); - var m; - if (host === 'youtu.be') { - m = u.pathname.match(/^\/([A-Za-z0-9_-]{6,})$/); - return m ? { provider: 'youtube', objectId: m[1] } : null; - } - if (host.indexOf('youtube') !== -1) { - m = u.pathname.match(/^\/embed\/([A-Za-z0-9_-]{6,})$/); - return m ? { provider: 'youtube', objectId: m[1] } : null; - } - if (host.indexOf('vimeo') !== -1) { - m = u.pathname.match(/^\/video\/([0-9]+)$/); - return m ? { provider: 'vimeo', objectId: m[1] } : null; - } - if (host.indexOf('dailymotion') !== -1) { - m = u.pathname.match(/^\/embed\/video\/([A-Za-z0-9]{5,})$/); - return m ? { provider: 'dailymotion', objectId: m[1] } : null; - } - if (host === 'mediateca.educa.madrid.org') { - m = u.pathname.match(/^\/video\/([A-Za-z0-9]{8,})(?:\/fs)?$/); - return m ? { provider: 'mediateca-madrid', objectId: m[1] } : null; - } - return null; - } - - /** - * Render a width/height attribute value as a CSS length. - * - * @param {?string} value - * @param {string} fallback - * @returns {string} - */ - function cssSize(value, fallback) { - if (!value) { - return fallback; - } - return /^[0-9]+$/.test(String(value)) ? value + 'px' : String(value); - } - - /** - * Replace whitelisted/PDF iframes with placeholders that reserve their box and - * carry the embed id + url. Returns the created placeholder elements. - * - * @param {Document|Element} root A document or a container element to scan. - * @param {Object} counter {n:int} mutable id counter (kept across calls). - * @returns {Element[]} - */ - function promote(root, counter) { - var created = []; - var maker = root.ownerDocument || root; - var frames = root.querySelectorAll('iframe[src]'); - for (var i = 0; i < frames.length; i++) { - var frame = frames[i]; - if (frame.getAttribute('data-exe-embed-id')) { - continue; - } - var src = frame.getAttribute('src'); - if (!isPromotable(src)) { - continue; - } - var rect = frame.getBoundingClientRect ? frame.getBoundingClientRect() : { width: 0, height: 0 }; - var placeholder = maker.createElement('div'); - counter.n += 1; - placeholder.setAttribute('data-exe-embed-id', 'exe-embed-' + counter.n); - // Report an ABSOLUTE url: the shim runs inside the content, so resolve the - // (possibly relative) src against the content location. The parent relay - // cannot — it would resolve a relative url against the host page instead. - var absoluteUrl = src; - try { - absoluteUrl = new URL(src, window.location.href).href; - } catch (e) { - absoluteUrl = src; - } - placeholder.setAttribute('data-exe-embed-url', absoluteUrl); - // For recognised providers also stamp {provider, objectId} so the parent can - // rebuild the canonical URL from a fixed template (DEC-0067 id-only channel) - // instead of trusting the author URL. Unknown hosts keep URL-only mode. - var provider = extractProvider(absoluteUrl); - if (provider) { - placeholder.setAttribute('data-exe-embed-provider', provider.provider); - placeholder.setAttribute('data-exe-embed-object-id', provider.objectId); - } - placeholder.className = frame.className; - placeholder.style.display = 'block'; - placeholder.style.maxWidth = '100%'; - placeholder.style.width = cssSize(frame.getAttribute('width'), (rect.width || 0) + 'px'); - placeholder.style.height = cssSize(frame.getAttribute('height'), (rect.height || 0) + 'px'); - placeholder.style.background = '#000'; - frame.parentNode.replaceChild(placeholder, frame); - created.push(placeholder); - } - return created; - } - - /** - * Collect the geometry of every placeholder in the document. - * - * @param {Document} doc - * @returns {Object[]} - */ - function collect(doc) { - var embeds = []; - var nodes = doc.querySelectorAll('[data-exe-embed-id]'); - for (var i = 0; i < nodes.length; i++) { - var node = nodes[i]; - var rect = node.getBoundingClientRect(); - var rec = { - id: node.getAttribute('data-exe-embed-id'), - url: node.getAttribute('data-exe-embed-url'), - x: rect.left, - y: rect.top, - w: rect.width, - h: rect.height - }; - var provider = node.getAttribute('data-exe-embed-provider'); - var objectId = node.getAttribute('data-exe-embed-object-id'); - if (provider && objectId) { - rec.provider = provider; - rec.objectId = objectId; - } - embeds.push(rec); - } - return embeds; - } - - /** - * Bootstrap inside the package iframe (no-op outside the secure opaque origin). - * Browser-only glue (requires a framed, opaque-origin window); exercised by the - * Playwright/Firefox e2e (tests/e2e/embed.spec.cjs), not the happy-dom unit tests. - */ - /* v8 ignore start */ - function init() { - if (window.parent === window || !isOpaqueOrigin()) { - return; - } - var counter = { n: 0 }; - var scheduled = false; - var lastReported = ''; - - // force=true always posts (initial run, load, and parent 'request' pings — - // the parent may have just started listening or lost its state); observer - // -driven reports skip when the geometry did not actually change, so an - // attribute-noisy page (carousel animations, aria flips) cannot spam the - // parent with identical syncs. - function report(force) { - var embeds = collect(document); - var serialized = JSON.stringify(embeds); - if (!force && serialized === lastReported) { - return; - } - lastReported = serialized; - window.parent.postMessage({ type: 'exe-embed', action: 'sync', embeds: embeds }, '*'); - } - function schedule() { - if (scheduled) { - return; - } - scheduled = true; - window.requestAnimationFrame(function () { - scheduled = false; - report(false); - }); - } - function run() { - promote(document, counter); - report(true); - } - - run(); - if (window.MutationObserver) { - // attributes too, not just childList: layout-affecting UI (the exported - // page's nav toggle, accordions) usually flips a class/style on an - // existing node, which reflows the placeholders without adding or - // removing any element. The filter keeps the observer to the - // reflow-causing attributes. - new MutationObserver(function () { - promote(document, counter); - schedule(); - }).observe(document.documentElement, { - childList: true, - subtree: true, - attributes: true, - attributeFilter: ['class', 'style', 'hidden', 'open'], - }); - } - window.addEventListener('scroll', schedule, true); - window.addEventListener('resize', schedule); - // A class-toggled layout change usually ANIMATES (CSS transition on the nav - // drawer): the mutation fires at the start, so re-measure again when the - // transition/animation lands to report the settled geometry. - window.addEventListener('transitionend', schedule, true); - window.addEventListener('animationend', schedule, true); - if (window.ResizeObserver) { - // Catches content-box changes that fire no window resize (the drawer - // pushing the content column, images loading late and growing the page). - var resizeObserver = new ResizeObserver(schedule); - resizeObserver.observe(document.documentElement); - if (document.body) { - resizeObserver.observe(document.body); - } - } - window.addEventListener('load', function () { - report(true); - }); - window.addEventListener('message', function (event) { - if (event.source !== window.parent) { - return; - } - var data = event.data; - if (data && data.type === 'exe-embed' && data.action === 'request') { - run(); - } - }); - } - /* v8 ignore stop */ - - var exp = { - isOpaqueOrigin: isOpaqueOrigin, - isPdfUrl: isPdfUrl, - isCrossOriginHttps: isCrossOriginHttps, - isPromotable: isPromotable, - extractProvider: extractProvider, - promote: promote, - collect: collect, - init: init - }; - // Test runner (Vitest/Node) consumes module.exports. - if (typeof module !== 'undefined' && module.exports) { module.exports = exp; } - // Browser bootstrap consumes window.exeEmbedShim; auto-run inside the iframe. - if (typeof window !== 'undefined') { - window.exeEmbedShim = exp; - if (typeof document !== 'undefined') { - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); - } else { - init(); - } - } - } -})(); diff --git a/src/embed/exe_external_media/exe-external-media-child.min.js b/src/embed/exe_external_media/exe-external-media-child.min.js new file mode 100644 index 0000000..79bd060 --- /dev/null +++ b/src/embed/exe_external_media/exe-external-media-child.min.js @@ -0,0 +1,13 @@ +/*! + * eXeLearning external-media child bundle — https://github.com/exelearning/exelearning + * + * Copyright (C) 2026 eXeLearning Team + * + * Dual-licensed so these bytes can ship inside eXeLearning (AGPL-3.0-or-later) + * and inside the GPL-3.0-or-later host plugins without either project + * relicensing them. Combining is already permitted by GPLv3 s13 and AGPLv3 s13; + * combining never relicenses, so this grant is what makes vendoring lawful. + * + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-3.0-or-later + */ +(()=>{var le=Object.defineProperty,ue=Object.defineProperties,ce=Object.getOwnPropertyDescriptors,B=Object.getOwnPropertyNames,z=Object.getOwnPropertySymbols,de=Object.prototype.hasOwnProperty,fe=Object.prototype.propertyIsEnumerable,Y=(e,t,r)=>t in e?le(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r,I=(e,t)=>{for(var r in t||(t={}))de.call(t,r)&&Y(e,r,t[r]);if(z)for(var r of z(t))fe.call(t,r)&&Y(e,r,t[r]);return e},me=(e,t)=>ue(e,ce(t)),_=(e,t)=>function(){return e&&(t=(0,e[B(e)[0]])(e=0)),t},pe=(e,t)=>function(){return t||(0,e[B(e)[0]])((t={exports:{}}).exports,t),t.exports};function ve({warn:e}={}){const t=new Set,r=[],n=e!=null?e:(o=>console.warn(o));return{notice(o,a){t.has(o)||(t.add(o),r.push(o),n(`[exe] window.${o} is deprecated and will be removed in a future major. Use ${a}. It still works for now; nothing needs changing today.`))},announced(){return[...r]}}}function j(e,t,r,n){return typeof Proxy=="undefined"?e:new Proxy(e,{get(o,a,i){return n.notice(t,r),Reflect.get(o,a,i)}})}var he=_({"src/shared/external-media/compatibility/legacy-globals.ts"(){}});function _e(e){return typeof e=="string"&&V.includes(e)}function ye(e){return typeof e=="string"&&F.includes(e)}var g,O,b,V,F,w=_({"src/shared/external-media/protocol/messages.ts"(){g=1,O="exe-embed",b="exe-media",V=["open","play","pause","seek","getCurrentTime","getDuration","hide","show","close"],F=["ready","play","pause","ended","timeupdate","seeked","state","error","closed"]}});function Z(e,t){const r=e.toLowerCase().replace(/\.$/,"");return r===t||r.endsWith(`.${t}`)}var W,H,J,ge=_({"src/shared/external-media/providers/types.ts"(){W="allow-scripts allow-same-origin allow-popups allow-forms allow-presentation",H="autoplay; encrypted-media; fullscreen; picture-in-picture; clipboard-write",J="strict-origin-when-cross-origin"}});function be(e){var t;return(t=ee.get(e))!=null?t:null}function Ee(e){var t;const r=e.hostname.toLowerCase().replace(/\.$/,"");return(t=D.find(n=>n.hosts.some(o=>Z(r,o))))!=null?t:null}function Ie(e){var t,r;let n;try{n=new URL(e)}catch(o){return null}return n.protocol!=="https:"||n.username||n.password?null:(r=(t=Ee(n))==null?void 0:t.parse(n))!=null?r:null}var M,x,X,P,G,R,K,C,Q,D,ee,te=_({"src/shared/external-media/providers/registry.ts"(){ge(),M={passive:{supported:!0,sandbox:W,allow:H,referrerPolicy:J},controlled:{supported:!1},facade:{posterStrategy:"none"}},x=/^[A-Za-z0-9_-]{11}$/,X=I({id:"youtube",hosts:["youtube.com","youtu.be","youtube-nocookie.com"],resourceIdPattern:x,kind:"video",parse(e){var t,r,n,o;const a=e.hostname.toLowerCase().replace(/\.$/,"");let i="";return Z(a,"youtu.be")?i=(t=e.pathname.split("/").filter(Boolean)[0])!=null?t:"":i=(o=(n=e.searchParams.get("v"))!=null?n:(r=e.pathname.match(/^\/(?:embed|v|shorts)\/([^/?#]+)/))==null?void 0:r[1])!=null?o:"",x.test(i)?{provider:"youtube",resourceId:i,kind:"video"}:null},buildCanonicalEmbedUrl(e){return x.test(e)?`https://www.youtube-nocookie.com/embed/${e}`:null}},M),P=/^[0-9]{6,12}$/,G=I({id:"vimeo",hosts:["vimeo.com"],resourceIdPattern:P,kind:"video",parse(e){var t,r,n;const o=(n=(r=(t=e.pathname.match(/^\/video\/([0-9]+)/))==null?void 0:t[1])!=null?r:e.pathname.split("/").filter(Boolean)[0])!=null?n:"";return P.test(o)?{provider:"vimeo",resourceId:o,kind:"video"}:null},buildCanonicalEmbedUrl(e){return P.test(e)?`https://player.vimeo.com/video/${e}`:null}},M),R=/^[A-Za-z0-9]{5,}$/,K=I({id:"dailymotion",hosts:["dailymotion.com","dai.ly"],resourceIdPattern:R,kind:"video",parse(e){var t,r,n,o;const a=(o=(n=(t=e.pathname.match(/^\/embed\/video\/([A-Za-z0-9]+)/))==null?void 0:t[1])!=null?n:(r=e.pathname.match(/^\/video\/([A-Za-z0-9]+)/))==null?void 0:r[1])!=null?o:"";return R.test(a)?{provider:"dailymotion",resourceId:a,kind:"video"}:null},buildCanonicalEmbedUrl(e){return R.test(e)?`https://www.dailymotion.com/embed/video/${e}`:null}},M),C=/^[A-Za-z0-9]{8,}$/,Q=I({id:"mediateca-madrid",hosts:["mediateca.educa.madrid.org"],resourceIdPattern:C,kind:"video",parse(e){var t,r;const n=(r=(t=e.pathname.match(/^\/video\/([A-Za-z0-9]+)(?:\/fs)?\/?$/))==null?void 0:t[1])!=null?r:"";return C.test(n)?{provider:"mediateca-madrid",resourceId:n,kind:"video"}:null},buildCanonicalEmbedUrl(e){return C.test(e)?`https://mediateca.educa.madrid.org/video/${e}/fs`:null}},M),D=[X,G,K,Q],ee=new Map(D.map(e=>[e.id,e]))}});function Me(e){return typeof e=="object"&&e!==null}function Ae(e,t,r){return typeof e=="number"&&Number.isFinite(e)&&e>=t&&e<=r}function E(e){return Ae(e,0,ne)}function re(e,t){return Me(e)&&e.type===t}function Oe(e){if(!re(e,b)||e.v!==g||!_e(e.action))return!1;switch(e.action){case"open":{if(!Number.isInteger(e.reqId)||!(e.start===void 0||e.start===null||E(e.start))||typeof e.provider!="string"||typeof e.videoId!="string")return!1;const t=be(e.provider);return!!t&&t.resourceIdPattern.test(e.videoId)}case"seek":return E(e.t);case"getCurrentTime":case"getDuration":return Number.isInteger(e.reqId);default:return!0}}function we(e){if(!re(e,b)||e.v!==g||!ye(e.action))return!1;switch(e.action){case"timeupdate":return E(e.currentTime)&&E(e.duration);case"seeked":return E(e.currentTime);case"state":return Number.isInteger(e.reqId);case"ready":return e.duration===void 0||e.duration===null||E(e.duration);case"error":return typeof e.code=="string"&&typeof e.fatal=="boolean";default:return!0}}var ne,xe=_({"src/shared/external-media/protocol/schemas.ts"(){te(),w(),ne=1e6}});function Pe(){let e=null;const t=new WeakSet;function r(n){const o=new Map;let a=new Map,i=0,u=!1;function h(s,p){var d;for(const y of(d=o.get(s))!=null?d:[])try{y(p)}catch(je){}}function f(s){if(u)return;const p=me(I({},s),{type:b,v:g});Oe(p)&&n.postMessage(p)}function m(s,p){return new Promise(d=>{if(u){d(null);return}i+=1;const y=i;a.set(y,{resolve:d,field:p}),f({action:s,reqId:y})})}const v={on(s,p){var d;return o.set(s,[...(d=o.get(s))!=null?d:[],p]),v},off(s,p){var d;return o.set(s,((d=o.get(s))!=null?d:[]).filter(y=>y!==p)),v},open(s){i+=1,f({action:"open",reqId:i,provider:s.provider,videoId:s.videoId,start:s.start,autoplay:s.autoplay})},play:()=>f({action:"play"}),pause:()=>f({action:"pause"}),seek:s=>f({action:"seek",t:s}),hide:()=>f({action:"hide"}),show:()=>f({action:"show"}),close:()=>f({action:"close"}),getCurrentTime:()=>m("getCurrentTime","currentTime"),getDuration:()=>m("getDuration","duration"),isRetired:()=>u};function c(s){var p;if(u||!we(s))return;const d=s;if(d.action==="state"){const y=a.get(d.reqId);if(!y)return;a.delete(d.reqId),y.resolve((p=d[y.field])!=null?p:null);return}h(d.action,d)}function l(){if(!u){for(const s of a.values())s.resolve(null);a=new Map,h("closed",{type:b,v:g,action:"closed",superseded:!0}),u=!0,o.clear()}}return{controller:v,deliver:c,retire:l}}return{create(n){var o;const a=r(n);return e&&e.controller!==a.controller&&e.retire(),e=a,t.has(n)||(t.add(n),n.onmessage=i=>e==null?void 0:e.deliver(i==null?void 0:i.data),(o=n.start)==null||o.call(n)),a.controller}}}var Re=_({"src/shared/external-media/media/controller.ts"(){w(),xe()}});function Ce({win:e,createHelloId:t}){const r=Pe();let n=null;function o(){const a=e.parent;return a?new Promise(i=>{const u=t();let h=!1;function f(m){var v;if(h)return;const c=m;if((c==null?void 0:c.source)!==a)return;const l=c.data;if(!l||l.type!==b||l.v!==g||l.action!=="welcome"||l.helloId!==u)return;const s=(v=c.ports)==null?void 0:v[0];s&&(h=!0,e.removeEventListener("message",f),i(s))}e.addEventListener("message",f);try{a.postMessage({type:b,v:g,action:"hello",helloId:u},"*")}catch(m){}}):Promise.resolve(null)}return{session(){return n!=null||(n=o()),n},async openMedia(a){const i=await this.session();if(!i)return null;const u=r.create(i);return u.open(a),u}}}var De=_({"src/shared/external-media/media/media-child.ts"(){w(),Re()}});function L(e){var t;try{return(t=e.document)==null||t.cookie,e.origin==="null"}catch(r){return!0}}function S(e){try{return!!e&&e.parent!==e}catch(t){return!0}}function oe(e){var t;return(t=e.location)==null?void 0:t.href}function Le(e,t){try{const r=new URL(e,t);if(r.protocol!=="https:")return!1;const n=r.hostname.toLowerCase().replace(/\.$/,""),o=new URL(t!=null?t:"").hostname.toLowerCase().replace(/\.$/,"");return n!==o}catch(r){return!1}}function Se(e,t){try{return/\.pdf$/i.test(new URL(e,t).pathname)}catch(r){return!1}}function ie(e,t){return Le(e,t)||Se(e,t)}var T=_({"src/shared/external-media/child/environment.ts"(){}});function Te(e,t,r={}){let n=!1,o="";function a(c){try{e.parent.postMessage(c,"*")}catch(l){}}function i(c){const l=t.collect(),s=JSON.stringify(l);!c&&s===o||(o=s,a({type:O,action:"sync",embeds:l}))}function u(){t.promote(),i(!0)}function h(){var c;n||(n=!0,(c=r.onActivate)==null||c.call(r),u())}function f(c){n||(a({type:O,action:"hello"}),c<$.length&&e.setTimeout(()=>f(c+1),$[c]))}function m(c){if(!c||c.source!==e.parent)return;const l=c.data;if(!(!l||l.type!==O)){if(l.action==="welcome"){n?u():h();return}l.action==="request"&&(n?i(!0):f(0))}}function v(){return!S(e)||!L(e)?!1:(e.addEventListener("message",m),f(0),!0)}return{start:v,isActivated:()=>n,handleHostMessage:m,refresh(){n&&i(!1)},rescan(){n&&(t.promote(),i(!1))},resync(){n&&i(!0)}}}var $,$e=_({"src/shared/external-media/child/child-runtime.ts"(){w(),T(),$=[250,750,1500,3e3]}});function ae(e,t){return e?/^[0-9]+$/.test(e)?`${e}px`:e:t}function Ne(e,t,r){var n,o,a,i;const u=oe(r),h=(n=e.ownerDocument)!=null?n:e,f=[];for(const m of Array.from(e.querySelectorAll("iframe[src]"))){if(m.getAttribute(A))continue;const v=m.getAttribute("src");if(!v||!ie(v,u))continue;const c=(a=(o=m.getBoundingClientRect)==null?void 0:o.call(m))!=null?a:{width:0,height:0},l=h.createElement("div");t.n+=1,l.setAttribute(A,`exe-embed-${t.n}`);let s=v;try{s=new URL(v,u).href}catch(y){s=v}l.setAttribute(N,s);const p=Ie(s);p&&(l.setAttribute(k,p.provider),l.setAttribute(U,p.resourceId)),l.className=m.className;const d=l.style;d.display="block",d.maxWidth="100%",d.width=ae(m.getAttribute("width"),`${c.width||0}px`),d.height=ae(m.getAttribute("height"),`${c.height||0}px`),d.background="#000",(i=m.parentNode)==null||i.replaceChild(l,m),f.push(l)}return f}function ke(e){return Array.from(e.querySelectorAll(`[${A}]`)).map(t=>{var r,n;const o=t.getBoundingClientRect(),a={id:(r=t.getAttribute(A))!=null?r:"",url:(n=t.getAttribute(N))!=null?n:"",x:o.left,y:o.top,w:o.width,h:o.height},i=t.getAttribute(k),u=t.getAttribute(U);return i&&u&&(a.provider=i,a.objectId=u),a})}var A,N,k,U,Ue=_({"src/shared/external-media/child/embed-scanner.ts"(){te(),T(),A="data-exe-embed-id",N="data-exe-embed-url",k="data-exe-embed-provider",U="data-exe-embed-object-id"}});function se(e){const t={n:0},r=()=>{var o;return(o=e.document.body)!=null?o:e.document};let n=!1;return{promote:()=>{if(!n){n=!0;try{Ne(r(),t,e)}finally{n=!1}}},collect:()=>ke(r())}}function q(e){const t=se(e),r=Te(e,t,{onActivate:n});function n(){if(e.MutationObserver&&e.document.documentElement&&new e.MutationObserver(()=>r.rescan()).observe(e.document.documentElement,{childList:!0,subtree:!0,attributes:!0}),e.addEventListener("scroll",()=>r.refresh(),!0),e.addEventListener("resize",()=>r.refresh()),e.addEventListener("transitionend",()=>r.refresh(),!0),e.addEventListener("animationend",()=>r.refresh(),!0),e.ResizeObserver){const o=new e.ResizeObserver(()=>r.refresh());e.document.documentElement&&o.observe(e.document.documentElement),e.document.body&&o.observe(e.document.body)}e.addEventListener("load",()=>r.resync())}return{started:r.start(),runtime:r}}function qe(e,t){e.readyState==="loading"?e.addEventListener("DOMContentLoaded",t):t()}function Be(e,t={}){const r=ve(t),n=Ce({win:e,createHelloId:()=>{var i,u;const h=e.crypto;return(u=(i=h==null?void 0:h.randomUUID)==null?void 0:i.call(h))!=null?u:`h-${Math.random().toString(36).slice(2)}`}});e.exeExternalMediaChild={start:()=>q(e),createDomScanner:se,media:n};const o={openMedia:i=>n.openMedia(i),ensureSession:()=>n.session(),inIframe:()=>S(e),isSandboxedOpaque:()=>L(e)};e.exeMediaBridge=j(o,"exeMediaBridge","window.exeExternalMediaChild.media.openMedia()",r);const a={createRuntime:(i=e)=>q(i).runtime,isOpaqueOrigin:L,isFramed:S,isPromotable:ie,contentBase:oe};e.exeEmbedShim=j(a,"exeEmbedShim","window.exeExternalMediaChild.start()",r)}var ze=_({"src/shared/external-media/child-entry.ts"(){he(),De(),$e(),Ue(),T()}}),Ye=pe({"src/shared/external-media/child-bundle.ts"(){ze(),typeof window!="undefined"&&(Be(window),qe(window.document,()=>q(window)))}});Ye()})(); diff --git a/src/embed/exe_external_media/exe-external-media-host.min.js b/src/embed/exe_external_media/exe-external-media-host.min.js new file mode 100644 index 0000000..cd1d07d --- /dev/null +++ b/src/embed/exe_external_media/exe-external-media-host.min.js @@ -0,0 +1,13 @@ +/*! + * eXeLearning external-media host bundle — https://github.com/exelearning/exelearning + * + * Copyright (C) 2026 eXeLearning Team + * + * Dual-licensed so these bytes can ship inside eXeLearning (AGPL-3.0-or-later) + * and inside the GPL-3.0-or-later host plugins without either project + * relicensing them. Combining is already permitted by GPLv3 s13 and AGPLv3 s13; + * combining never relicenses, so this grant is what makes vendoring lawful. + * + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-3.0-or-later + */ +(()=>{var Ae=Object.defineProperty,G=Object.getOwnPropertyNames,R=Object.getOwnPropertySymbols,J=Object.prototype.hasOwnProperty,K=Object.prototype.propertyIsEnumerable,Q=(e,t,o)=>t in e?Ae(e,t,{enumerable:!0,configurable:!0,writable:!0,value:o}):e[t]=o,C=(e,t)=>{for(var o in t||(t={}))J.call(t,o)&&Q(e,o,t[o]);if(R)for(var o of R(t))K.call(t,o)&&Q(e,o,t[o]);return e},Le=(e,t)=>{var o={};for(var n in e)J.call(e,n)&&t.indexOf(n)<0&&(o[n]=e[n]);if(e!=null&&R)for(var n of R(e))t.indexOf(n)<0&&K.call(e,n)&&(o[n]=e[n]);return o},E=(e,t)=>function(){return e&&(t=(0,e[G(e)[0]])(e=0)),t},Re=(e,t)=>function(){return t||(0,e[G(e)[0]])((t={exports:{}}).exports,t),t.exports};function Te({warn:e}={}){const t=new Set,o=[],n=e!=null?e:(a=>console.warn(a));return{notice(a,u){t.has(a)||(t.add(a),o.push(a),n(`[exe] window.${a} is deprecated and will be removed in a future major. Use ${u}. It still works for now; nothing needs changing today.`))},announced(){return[...o]}}}function ee(e,t,o,n){return typeof Proxy=="undefined"?e:new Proxy(e,{get(a,u,c){return n.notice(t,o),Reflect.get(a,u,c)}})}var $e=E({"src/shared/external-media/compatibility/legacy-globals.ts"(){}});function Se(e){return typeof e=="string"&&te.includes(e)}function De(e){return typeof e=="string"&&re.includes(e)}var O,k,A,te,re,T=E({"src/shared/external-media/protocol/messages.ts"(){O=1,k="exe-embed",A="exe-media",te=["hello","sync"],re=["open","play","pause","seek","getCurrentTime","getDuration","hide","show","close"]}});function Ne({doc:e,translate:t,onClose:o}){var n,a,u;let c=!1,s=!1;const r=e.createElement("dialog");r.className="exe-media-modal",r.setAttribute("aria-label",t("Video player"));try{r.setAttribute("closedby","any")}catch(v){}const l=e.createElement("button");l.setAttribute("type","button"),l.className="exe-media-modal__close",l.setAttribute("aria-label",t("Close video")),l.textContent=t("Close");const i=e.createElement("div");i.className="exe-media-modal__body",r.appendChild(l),r.appendChild(i);function f(){var v,g;c||(c=!0,(v=r.close)==null||v.call(r),(g=r.remove)==null||g.call(r),o())}return l.addEventListener("click",f),r.addEventListener("click",v=>{(v==null?void 0:v.target)===r&&f()}),r.addEventListener("close",()=>{if(s){s=!1;return}f()}),(a=(n=e.body)!=null?n:e.documentElement)==null||a.appendChild(r),(u=r.showModal)==null||u.call(r),{body:()=>i,show:()=>{var v;return(v=r.showModal)==null?void 0:v.call(r)},hide(){var v;c||(s=!0,(v=r.close)==null||v.call(r),s=!1)},close:f,isClosed:()=>c}}var Ue=E({"src/shared/external-media/media/modal.ts"(){}});function Fe(e){const{container:t,doc:o,win:n,dialect:a,videoId:u,origin:c,start:s,autoplay:r,on:l}=e;if(!a)return null;const i=o.createElement("iframe"),f={currentTime:0,duration:0};let v=!1;function g(d){var y;if(!(v||!d))try{(y=i.contentWindow)==null||y.postMessage(JSON.stringify(d),a.targetOrigin)}catch(h){}}function P(d){var y,h,_;d===$.playing?(y=l.play)==null||y.call(l):d===$.paused?(h=l.pause)==null||h.call(l):d===$.ended&&((_=l.ended)==null||_.call(l))}function p(d,y){var h;typeof d=="number"&&(f.currentTime=d),typeof y=="number"&&(f.duration=y),(h=l.time)==null||h.call(l,f.currentTime,f.duration)}function m(d){var y,h,_,b,w;if(v)return;const x=d;if((x==null?void 0:x.source)!==i.contentWindow)return;const I=a.decodeEvent(x.data);if(I)switch(I.kind){case"ready":for(const wt of a.subscribeCommands())g(wt);(y=l.ready)==null||y.call(l,f.duration||void 0);break;case"play":(h=l.play)==null||h.call(l);break;case"pause":(_=l.pause)==null||_.call(l);break;case"ended":(b=l.ended)==null||b.call(l);break;case"error":(w=l.error)==null||w.call(l,I.code);break;case"info":p(I.currentTime,I.duration),P(I.playerState);break;case"timeupdate":p(I.currentTime,I.duration);break;case"state":P(I.playerState);break}}return i.setAttribute("allow","autoplay; encrypted-media; fullscreen; picture-in-picture"),i.setAttribute("allowfullscreen",""),i.setAttribute("referrerpolicy","strict-origin-when-cross-origin"),Object.assign(i.style,{border:"0",width:"100%",height:"100%"}),i.setAttribute("src",a.buildPlayerUrl(u,{origin:c,start:s,autoplay:r})),n.addEventListener("message",m),i.addEventListener("load",()=>{for(const d of a.subscribeCommands())g(d)}),t.appendChild(i),{play:()=>g(a.encodeCommand("play")),pause:()=>g(a.encodeCommand("pause")),seek:d=>g(a.encodeCommand("seek",d)),currentTime:()=>f.currentTime,duration:()=>f.duration,destroy(){var d;v||(v=!0,n.removeEventListener("message",m),(d=i.parentNode)==null||d.removeChild(i))}}}var $,je=E({"src/shared/external-media/media/player-adapter.ts"(){$={ended:0,playing:1,paused:2}}});function M(e){return typeof e=="number"&&Number.isFinite(e)?e:void 0}function ne(e){const t=Number(e);return Number.isFinite(t)&&t>0?t:0}function oe(e){if(typeof e=="string")try{const t=JSON.parse(e);return typeof t=="object"&&t!==null?t:null}catch(t){return null}return typeof e=="object"&&e!==null?e:null}function He(e){var t;return(t=se[e])!=null?t:null}var W,ae,Y,le,ie,se,We=E({"src/shared/external-media/media/provider-dialects.ts"(){W="https://www.youtube-nocookie.com",ae={id:"youtube",targetOrigin:W,buildPlayerUrl(e,{origin:t,start:o,autoplay:n}){const a=new URL(`${W}/embed/${encodeURIComponent(e)}`);return a.searchParams.set("enablejsapi","1"),a.searchParams.set("rel","0"),a.searchParams.set("modestbranding","1"),t&&a.searchParams.set("origin",t),o&&a.searchParams.set("start",String(Math.floor(o))),n&&a.searchParams.set("autoplay","1"),a.href},encodeCommand(e,t){switch(e){case"play":return{event:"command",func:"playVideo",args:[]};case"pause":return{event:"command",func:"pauseVideo",args:[]};case"seek":return{event:"command",func:"seekTo",args:[ne(t),!0]};default:return null}},subscribeCommands:()=>[{event:"listening"}],decodeEvent(e){var t;const o=oe(e);if(!o||typeof o.event!="string")return null;if(o.event==="onReady")return{kind:"ready"};if(o.event==="onError")return{kind:"error",code:String((t=o.info)!=null?t:"")};if(o.event==="infoDelivery"&&typeof o.info=="object"&&o.info!==null){const n=o.info;return{kind:"info",currentTime:M(n.currentTime),duration:M(n.duration),playerState:M(n.playerState)}}if(o.event==="onStateChange"){const n=typeof o.info=="object"&&o.info!==null?o.info.playerState:o.info;return{kind:"state",playerState:M(n)}}return null}},Y="https://player.vimeo.com",le=["playProgress","timeupdate","play","pause","ended","error"],ie={id:"vimeo",targetOrigin:Y,buildPlayerUrl(e,{autoplay:t}){const o=new URL(`${Y}/video/${encodeURIComponent(e)}`);return o.searchParams.set("api","1"),o.searchParams.set("player_id",`exe-vimeo-${e}`),t&&o.searchParams.set("autoplay","1"),o.href},encodeCommand(e,t){switch(e){case"play":return{method:"play"};case"pause":return{method:"pause"};case"seek":return{method:"setCurrentTime",value:ne(t)};default:return null}},subscribeCommands:()=>le.map(e=>({method:"addEventListener",value:e})),decodeEvent(e){const t=oe(e);if(!t||typeof t.event!="string")return null;switch(t.event){case"ready":return{kind:"ready"};case"play":return{kind:"play"};case"pause":return{kind:"pause"};case"ended":case"finish":return{kind:"ended"};case"error":return{kind:"error",code:"vimeo_error"};case"playProgress":case"timeupdate":{if(typeof t.data!="object"||t.data===null)return null;const o=t.data;return{kind:"timeupdate",currentTime:M(o.seconds),duration:M(o.duration)}}default:return null}}},se={youtube:ae,vimeo:ie}}});function Ye(e,t){const o=e.toLowerCase().replace(/\.$/,"");return o===t||o.endsWith(`.${t}`)}var B,V,q,ue=E({"src/shared/external-media/providers/types.ts"(){B="allow-scripts allow-same-origin allow-popups allow-forms allow-presentation",V="autoplay; encrypted-media; fullscreen; picture-in-picture; clipboard-write",q="strict-origin-when-cross-origin"}});function z(e){var t;return(t=ve.get(e))!=null?t:null}function Be(e,t){var o,n;return(n=(o=z(e))==null?void 0:o.buildCanonicalEmbedUrl(t))!=null?n:null}var L,S,de,D,ce,N,fe,U,me,pe,ve,X=E({"src/shared/external-media/providers/registry.ts"(){ue(),L={passive:{supported:!0,sandbox:B,allow:V,referrerPolicy:q},controlled:{supported:!1},facade:{posterStrategy:"none"}},S=/^[A-Za-z0-9_-]{11}$/,de=C({id:"youtube",hosts:["youtube.com","youtu.be","youtube-nocookie.com"],resourceIdPattern:S,kind:"video",parse(e){var t,o,n,a;const u=e.hostname.toLowerCase().replace(/\.$/,"");let c="";return Ye(u,"youtu.be")?c=(t=e.pathname.split("/").filter(Boolean)[0])!=null?t:"":c=(a=(n=e.searchParams.get("v"))!=null?n:(o=e.pathname.match(/^\/(?:embed|v|shorts)\/([^/?#]+)/))==null?void 0:o[1])!=null?a:"",S.test(c)?{provider:"youtube",resourceId:c,kind:"video"}:null},buildCanonicalEmbedUrl(e){return S.test(e)?`https://www.youtube-nocookie.com/embed/${e}`:null}},L),D=/^[0-9]{6,12}$/,ce=C({id:"vimeo",hosts:["vimeo.com"],resourceIdPattern:D,kind:"video",parse(e){var t,o,n;const a=(n=(o=(t=e.pathname.match(/^\/video\/([0-9]+)/))==null?void 0:t[1])!=null?o:e.pathname.split("/").filter(Boolean)[0])!=null?n:"";return D.test(a)?{provider:"vimeo",resourceId:a,kind:"video"}:null},buildCanonicalEmbedUrl(e){return D.test(e)?`https://player.vimeo.com/video/${e}`:null}},L),N=/^[A-Za-z0-9]{5,}$/,fe=C({id:"dailymotion",hosts:["dailymotion.com","dai.ly"],resourceIdPattern:N,kind:"video",parse(e){var t,o,n,a;const u=(a=(n=(t=e.pathname.match(/^\/embed\/video\/([A-Za-z0-9]+)/))==null?void 0:t[1])!=null?n:(o=e.pathname.match(/^\/video\/([A-Za-z0-9]+)/))==null?void 0:o[1])!=null?a:"";return N.test(u)?{provider:"dailymotion",resourceId:u,kind:"video"}:null},buildCanonicalEmbedUrl(e){return N.test(e)?`https://www.dailymotion.com/embed/video/${e}`:null}},L),U=/^[A-Za-z0-9]{8,}$/,me=C({id:"mediateca-madrid",hosts:["mediateca.educa.madrid.org"],resourceIdPattern:U,kind:"video",parse(e){var t,o;const n=(o=(t=e.pathname.match(/^\/video\/([A-Za-z0-9]+)(?:\/fs)?\/?$/))==null?void 0:t[1])!=null?o:"";return U.test(n)?{provider:"mediateca-madrid",resourceId:n,kind:"video"}:null},buildCanonicalEmbedUrl(e){return U.test(e)?`https://mediateca.educa.madrid.org/video/${e}/fs`:null}},L),pe=[de,ce,fe,me],ve=new Map(pe.map(e=>[e.id,e]))}});function ye(e){return typeof e=="object"&&e!==null}function Z(e,t,o){return typeof e=="number"&&Number.isFinite(e)&&e>=t&&e<=o}function he(e){return Z(e,0,Ee)}function _e(e){return Z(e,-F,F)}function ge(e){return Z(e,0,F)}function be(e,t){return ye(e)&&e.type===t}function Ve(e){return!(!ye(e)||typeof e.id!="string"||e.id.length===0||e.id.length>128||!_e(e.x)||!_e(e.y)||!ge(e.w)||!ge(e.h)||e.provider!==void 0&&typeof e.provider!="string"||e.objectId!==void 0&&typeof e.objectId!="string"||e.url!==void 0&&typeof e.url!="string")}function qe(e){return!be(e,k)||!Se(e.action)?!1:e.action==="sync"?!Array.isArray(e.embeds)||e.embeds.length>we?!1:e.embeds.every(Ve):!0}function ze(e){if(!be(e,A)||e.v!==O||!De(e.action))return!1;switch(e.action){case"open":{if(!Number.isInteger(e.reqId)||!(e.start===void 0||e.start===null||he(e.start))||typeof e.provider!="string"||typeof e.videoId!="string")return!1;const t=z(e.provider);return!!t&&t.resourceIdPattern.test(e.videoId)}case"seek":return he(e.t);case"getCurrentTime":case"getDuration":return Number.isInteger(e.reqId);default:return!0}}var Ee,we,F,xe=E({"src/shared/external-media/protocol/schemas.ts"(){X(),T(),Ee=1e6,we=64,F=1e5}});function Xe(e){if(typeof e!="object"||e===null)return!1;const t=e;return t.type===A&&t.v===O&&t.action==="hello"&&typeof t.helloId=="string"&&t.helloId.length>0}function Ze(e){const{contentWindow:t,createChannel:o,onCommand:n,onTeardown:a}=e;let u=null;function c(){var r,l;if(!u)return;const i=u;u=null,i.port.onmessage=null,a==null||a(i),(l=(r=i.port).close)==null||l.call(r)}function s(r){var l,i;const f=o(),v={helloId:r,port:f.port1};u=v,f.port1.onmessage=g=>{u===v&&ze(g==null?void 0:g.data)&&n(v,g.data)},(i=(l=f.port1).start)==null||i.call(l),t.postMessage({type:A,v:O,action:"welcome",helloId:r},"*",[f.port2])}return{handleWindowMessage(r){!r||r.source!==t||Xe(r.data)&&(u==null?void 0:u.helloId)!==r.data.helloId&&(c(),s(r.data.helloId))},current:()=>u,teardown:c}}var Ge=E({"src/shared/external-media/media/session.ts"(){T(),xe()}});function Je(e){const{contentWindow:t,doc:o,win:n,origin:a,translate:u,createChannel:c}=e;let s=null,r=null,l=!1;const i=Ze({contentWindow:t,createChannel:c,onCommand:P,onTeardown:()=>v()});function f(m,d){m.port.postMessage(C({type:A,v:O},d))}function v(){r==null||r.destroy(),r=null,s==null||s.close(),s=null}function g(m,d){const y=He(d.provider);if(!y){f(m,{action:"error",code:"unsupported_provider",fatal:!0});return}v();const h=Ne({doc:o,translate:u,onClose:()=>{r==null||r.destroy(),r=null,s=null,f(m,{action:"closed"})}});s=h,r=Fe({container:h.body(),doc:o,win:n,dialect:y,videoId:d.videoId,origin:a,start:d.start,autoplay:d.autoplay,on:{ready:_=>f(m,{action:"ready",duration:_}),play:()=>f(m,{action:"play"}),pause:()=>f(m,{action:"pause"}),ended:()=>f(m,{action:"ended"}),error:_=>f(m,{action:"error",code:_,fatal:!1}),time:(_,b)=>{typeof _!="number"||typeof b!="number"||b<=0||f(m,{action:"timeupdate",currentTime:_,duration:b})}}})}function P(m,d){var y,h;if(l)return;const _=d;switch(_.action){case"open":g(m,_);return;case"play":r==null||r.play();return;case"pause":r==null||r.pause();return;case"seek":r==null||r.seek(_.t);return;case"hide":s==null||s.hide();return;case"show":s==null||s.show();return;case"close":r==null||r.destroy(),r=null,s==null||s.close(),s=null;return;case"getCurrentTime":f(m,{action:"state",reqId:_.reqId,currentTime:(y=r==null?void 0:r.currentTime())!=null?y:0});return;case"getDuration":f(m,{action:"state",reqId:_.reqId,duration:(h=r==null?void 0:r.duration())!=null?h:0});return;default:return}}function p(m){l||i.handleWindowMessage(m)}return n.addEventListener("message",p),{detach(){l||(l=!0,n.removeEventListener("message",p),v(),i.teardown())}}}var Ke=E({"src/shared/external-media/media/media-host.ts"(){T(),Ue(),je(),We(),Ge()}});function Qe({doc:e,win:t,frameFor:o}){const n=new Map,a=new Map,u=(s,r)=>`${s}::${r}`;function c(s){var r;const l=n.get(s);if(l)return l;const i=e.createElement("div");return i.setAttribute("class",Pe),Object.assign(i.style,{position:"absolute",overflow:"hidden",pointerEvents:"none",zIndex:"2147483646"}),(r=e.body)==null||r.appendChild(i),n.set(s,i),i}return{measureFrame(s){const r=o(s);return r?r.getBoundingClientRect():null},isFrameObscured(s){var r,l,i;const f=o(s);if(!f)return!0;const v=f.getBoundingClientRect();if(v.width<=0||v.height<=0)return!0;const g=(r=t.innerWidth)!=null?r:0,P=(l=t.innerHeight)!=null?l:0,p=Math.max(v.left,0),m=Math.max(v.top,0),d=Math.min(v.left+v.width,g),y=Math.min(v.top+v.height,P);if(d<=p||y<=m)return!1;const h=(i=e.elementFromPoint)==null?void 0:i.call(e,(p+d)/2,(m+y)/2);if(!h||h===f)return!1;for(const _ of a.values())if(h===_)return!1;return!0},setOverlayHidden(s,r){c(s).style.display=r?"none":""},scrollOffset(){var s,r,l,i,f,v;return{x:(l=(r=t.pageXOffset)!=null?r:(s=e.documentElement)==null?void 0:s.scrollLeft)!=null?l:0,y:(v=(f=t.pageYOffset)!=null?f:(i=e.documentElement)==null?void 0:i.scrollTop)!=null?v:0}},positionOverlay(s,r){const l=c(s);l.style.left=`${r.left}px`,l.style.top=`${r.top}px`,l.style.width=`${r.width}px`,l.style.height=`${r.height}px`},mountPlayer(s,r,l){const i=e.createElement("iframe");i.setAttribute(j,"1"),l.sandbox!==void 0&&i.setAttribute("sandbox",l.sandbox),i.setAttribute("allow",l.allow),i.setAttribute("referrerpolicy",l.referrerPolicy),l.allowFullscreen&&i.setAttribute("allowfullscreen",""),Object.assign(i.style,{position:"absolute",border:"0",pointerEvents:"auto"}),i.setAttribute("src",l.src),c(s).appendChild(i),a.set(u(s,r),i)},positionPlayer(s,r,l){const i=a.get(u(s,r));i&&(i.style.left=`${l.left}px`,i.style.top=`${l.top}px`,i.style.width=`${l.width}px`,i.style.height=`${l.height}px`)},unmountPlayer(s,r){var l;const i=u(s,r),f=a.get(i);f&&((l=f.parentNode)==null||l.removeChild(f),a.delete(i))},postToFrame(s,r){var l,i;(i=(l=o(s))==null?void 0:l.contentWindow)==null||i.postMessage(r,"*")}}}var j,Pe,et=E({"src/shared/external-media/host/dom-overlay-adapter.ts"(){j="data-exe-embed-player",Pe="exe-embed-overlay"}});function tt(){const e=new Map;let t=0;function o(n,a,u){return{handle:n,source:a,contentSrc:u,welcomed:!1,lastRect:null,players:[]}}return{register(n,a){for(const[c,s]of e)if(s.source===n)return e.set(c,o(c,n,a)),c;const u=`frame-${++t}`;return e.set(u,o(u,n,a)),u},unregister(n){e.delete(n)},resolve(n){if(n==null)return null;for(const a of e.values())if(a.source===n)return a;return null},get(n){var a;return(a=e.get(n))!=null?a:null},welcome(n){const a=e.get(n);a&&(a.welcomed=!0)},invalidate(n,a){const u=e.get(n);u&&e.set(n,o(n,u.source,a!=null?a:u.contentSrc))},setLastRect(n,a){const u=e.get(n);u&&(u.lastRect=a)},setPlayers(n,a){const u=e.get(n);u&&(u.players=a)},all(){return[...e.values()]}}}var rt=E({"src/shared/external-media/host/frame-registry.ts"(){}});function Ie(e,t){return{left:e.left+t.x,top:e.top+t.y,width:e.width,height:e.height}}function nt(e,t){return e?e.left!==t.left||e.top!==t.top||e.width!==t.width||e.height!==t.height:!0}function ot(e,t){return{left:e.x,top:e.y,width:Math.min(e.w,t.width),height:Math.min(e.h,t.height)}}function at(e,t){const o=new Map(e.map(s=>[s.id,s])),n=[],a=[],u=[],c=new Set;for(const s of t){c.add(s.embed.id);const r=o.get(s.embed.id);if(!r){n.push(s);continue}if(r.src!==s.url){u.push(r.id),n.push(s);continue}a.push(s)}for(const s of e)c.has(s.id)||u.push(s.id);return{toCreate:n,toReposition:a,toRemove:u}}var lt=E({"src/shared/external-media/host/overlay-geometry.ts"(){}});function it(e){return e.kind==="video"?{src:e.url,sandbox:B,allow:V,referrerPolicy:q,allowFullscreen:!0}:e.sameOrigin?{src:e.url,allow:"fullscreen",referrerPolicy:"no-referrer",allowFullscreen:!1}:{src:e.url,sandbox:"allow-same-origin",allow:"fullscreen",referrerPolicy:"no-referrer",allowFullscreen:!1}}var st=E({"src/shared/external-media/host/player-descriptor.ts"(){ue()}});function H(e){return(e||"").toLowerCase().replace(/\.$/,"")}function ut(e){return!!(!e||e==="localhost"||/\.localhost$/.test(e)||/\.local$/.test(e)||e.startsWith("[")||e.includes(":")||/^\d{1,3}(\.\d{1,3}){3}$/.test(e))}function dt(e,t){const o=H(e),n=H(t);return n?o===n||o.endsWith(`.${n}`)||n.endsWith(`.${o}`):!1}function Ce(e,t){if(e.protocol!=="https:"||e.username||e.password||e.origin===t.origin)return!1;const o=H(e.hostname);return!(ut(o)||dt(o,t.hostname))}function ct(e){try{return new URL(e).href.replace(/[^/]*([?#].*)?$/,"")}catch(t){return""}}function ft(e){var t,o;return(o=(t=String(e).match(/[a-f0-9]{12,}/i))==null?void 0:t[0])!=null?o:null}function mt(e,t){const o=ct(t);if(o&&e.href.startsWith(o))return!0;const n=ft(t);return!!(n&&e.pathname.includes(`/${n}/`))}function pt(e,t,o,n={}){let a;try{a=new URL(e)}catch(u){return null}if(a.username||a.password)return null;if(/\.pdf$/i.test(a.pathname))return a.origin===o.origin?mt(a,t)?{url:a.href,kind:"pdf",sameOrigin:!0}:null:Ce(a,o)?{url:a.href,kind:"pdf"}:null;if(n.strict){const u=H(a.hostname);if(n.allowlist&&!n.allowlist.includes(u)||a.protocol!=="https:")return null;for(const c of["youtube","vimeo","dailymotion","mediateca-madrid"]){const s=z(c);if(!(s!=null&&s.hosts.some(i=>u===i||u.endsWith(`.${i}`))))continue;const r=s.parse(a);if(!r)return null;const l=s.buildCanonicalEmbedUrl(r.resourceId);return l?{url:l,kind:"video"}:null}return null}return Ce(a,o)?{url:a.href,kind:"video"}:null}var vt=E({"src/shared/external-media/host/url-policy.ts"(){X()}});function yt(e){var t;return e.provider&&e.objectId?Be(e.provider,e.objectId):(t=e.url)!=null?t:null}function ht(e){var t=e,{location:o,registry:n,adapter:a}=t,u=Le(t,["location","registry","adapter"]);function c(r){var l,i,f;(f=a.setOverlayHidden)==null||f.call(a,r,(i=(l=a.isFrameObscured)==null?void 0:l.call(a,r))!=null?i:!1)}function s(r,l,i){var f;const v=a.measureFrame(r);if(!v)return;c(r),a.positionOverlay(r,Ie(v,a.scrollOffset())),n.setLastRect(r,v);const g=[],P=new Map;for(const d of l){const y=yt(d);if(!y)continue;const h=pt(y,i,o,u);h&&(g.push({embed:d,url:h.url}),P.set(d.id,h))}const p=n.get(r),m=at((f=p==null?void 0:p.players)!=null?f:[],g);for(const d of m.toRemove)a.unmountPlayer(r,d);for(const d of m.toCreate){const y=P.get(d.embed.id);y&&a.mountPlayer(r,d.embed.id,it(y))}for(const d of[...m.toCreate,...m.toReposition])a.positionPlayer(r,d.embed.id,ot(d.embed,v));n.setPlayers(r,g.map(d=>({id:d.embed.id,src:d.url})))}return{attach(r,l){return n.register(r,l)},detach(r){var l;const i=n.get(r);for(const f of(l=i==null?void 0:i.players)!=null?l:[])a.unmountPlayer(r,f.id);n.unregister(r)},handleMessage(r){const l=n.resolve(r==null?void 0:r.source);if(!l)return;const i=r.data;if(!(!i||i.type!==k)){if(i.action==="hello"){n.welcome(l.handle),a.postToFrame(l.handle,{type:k,action:"welcome"});return}if(i.action==="sync"){if(!l.welcomed||!qe(i))return;s(l.handle,i.embeds,l.contentSrc)}}},requestSync(r){const l=r===void 0?n.all():[n.get(r)];for(const i of l)i&&a.postToFrame(i.handle,{type:k,action:"request"})},checkDrift(){let r=0;for(const l of n.all()){const i=a.measureFrame(l.handle);i&&(c(l.handle),nt(l.lastRect,i)&&(a.positionOverlay(l.handle,Ie(i,a.scrollOffset())),n.setLastRect(l.handle,i),r+=1))}return r},notifyNavigated(r,l){var i;const f=n.get(r);for(const v of(i=f==null?void 0:f.players)!=null?i:[])a.unmountPlayer(r,v.id);n.invalidate(r,l)}}}var _t=E({"src/shared/external-media/host/host-runtime.ts"(){T(),xe(),lt(),st(),vt(),X()}});function Me(e,t={}){const o=tt(),n=new Map,a=new Map,u=Qe({doc:e.document,win:e,frameFor:p=>{var m;return(m=n.get(p))!=null?m:null}});let c=null,s=null,r=!1;function l(p){var m,d,y,h;if(!p||o.resolve(p))return;const _=(y=(d=(m=e.document).getElementsByTagName)==null?void 0:d.call(m,"iframe"))!=null?y:[];for(let b=0;b<_.length;b+=1){const w=_[b];if(w.contentWindow===p){if((h=w.getAttribute)!=null&&h.call(w,j))return;g(w);return}}}function i(p){const m=p!=null?p:{};c&&l(m.source),c==null||c.handleMessage(m)}function f(){if(r)return;r=!0;const p=()=>{r=!1,c==null||c.checkDrift()};e.requestAnimationFrame?e.requestAnimationFrame(p):p()}function v(){var p,m,d,y,h,_,b,w,x,I;return c||(c=ht(C(C({location:{origin:(m=(p=e.location)==null?void 0:p.origin)!=null?m:"",hostname:(y=(d=e.location)==null?void 0:d.hostname)!=null?y:""},registry:o,adapter:u},t),t.location?{location:t.location}:{})),(h=e.addEventListener)==null||h.call(e,"message",i),(_=e.addEventListener)==null||_.call(e,"scroll",f,!0),(b=e.addEventListener)==null||b.call(e,"resize",f),(w=e.addEventListener)==null||w.call(e,"transitionend",f,!0),(x=e.addEventListener)==null||x.call(e,"animationend",f,!0),s=(I=e.setInterval)==null?void 0:I.call(e,()=>c==null?void 0:c.checkDrift(),ke),c)}function g(p){var m,d;const y=v(),h=y.attach(p.contentWindow,(m=p.src)!=null?m:"");n.set(h,p);const _=()=>{var b,w;const x=(b=p.src)!=null?b:"";((w=o.get(h))==null?void 0:w.contentSrc)!==x&&y.notifyNavigated(h,x)};return(d=p.addEventListener)==null||d.call(p,"load",_),a.set(h,_),h}function P(p){var m;const d=n.get(p),y=a.get(p);d&&y&&((m=d.removeEventListener)==null||m.call(d,"load",y)),c==null||c.detach(p),n.delete(p),a.delete(p)}return{attach(p){const m=g(p);return{detach:()=>P(m)}},scan(){var p,m,d,y;const h=(d=(m=(p=e.document).getElementsByTagName)==null?void 0:m.call(p,"iframe"))!=null?d:[],_=new Set(n.values());let b=0;for(let w=0;w{var c,s;return(s=(c=a._)==null?void 0:c.call(a,u))!=null?s:u},createChannel:()=>new a.MessageChannel})}function gt(e,t={}){const o=Te(t);e.exeExternalMediaHost={create:(u={})=>Me(e,u),attachMedia:u=>Oe(e,u)};const n={attach(u){const c=Oe(e,u);return{detach:()=>c.detach()}}};e.exeMediaHost=ee(n,"exeMediaHost","window.exeExternalMediaHost.attachMedia(iframe)",o);const a={init(u={}){const c=Me(e,{strict:u.mode==="strict",allowlist:u.whitelist});return c.scan(),{clear:()=>c.clear(),reflow:()=>c.reflow(),dispose:()=>c.dispose(),init(){return c.scan(),this}}}};e.exeEmbedRelay=ee(a,"exeEmbedRelay","window.exeExternalMediaHost.create()",o)}var ke,bt=E({"src/shared/external-media/host-entry.ts"(){$e(),Ke(),et(),rt(),_t(),ke=300}}),Et=Re({"src/shared/external-media/host-bundle.ts"(){bt(),typeof window!="undefined"&>(window)}});Et()})(); diff --git a/src/embed/exe_external_media/exe-external-media.manifest.json b/src/embed/exe_external_media/exe-external-media.manifest.json new file mode 100644 index 0000000..aee6b5f --- /dev/null +++ b/src/embed/exe_external_media/exe-external-media.manifest.json @@ -0,0 +1,18 @@ +{ + "libraryVersion": "1.0.0", + "protocolVersion": 1, + "buildHash": "43c776daa551fd770b6dade520629362e5a8ca8745e4245c359447c4689b34d6", + "sourceCommit": "3bee9769e", + "files": { + "child": { + "path": "exe-external-media-child.min.js", + "sha256": "2b5466f3bcc66e1f793f7b15b75f2912b602cc383ae6031a8fa7a8f77ceedc8e", + "bytes": 12973 + }, + "host": { + "path": "exe-external-media-host.min.js", + "sha256": "3bb1cd19df99f8a981315fdeafafe26e4ad42ad0ca92995e257ccebdf7146f49", + "bytes": 25539 + } + } +} diff --git a/src/embed/exe_external_media/verify.mjs b/src/embed/exe_external_media/verify.mjs new file mode 100644 index 0000000..1a0f214 --- /dev/null +++ b/src/embed/exe_external_media/verify.mjs @@ -0,0 +1,129 @@ +/** + * Verify a VENDORED copy of the eXeLearning external-media distribution. + * + * This is the consumer's tool, and it ships inside the distribution itself — for the same + * reason the licence banner does: a check that lives only in eXeLearning's repository is + * no check at all to whoever received the files. + * + * It answers a different question from eXeLearning's own build verifier, which is why the + * two are not one file. Ours asks "is this distribution well-formed?" — size budgets, + * contract and protocol agreement, reproducibility against a fresh build. A consumer + * cannot ask that; it has no build to compare against. It asks "are these the bytes + * eXeLearning published, unmodified since?", which is answerable from the manifest alone. + * + * Deliberately dependency-free and plain ESM: it must run under `node` in a PHP plugin's + * CI with no toolchain, no install step and no package.json. + * + * node exe_external_media/verify.mjs [dir] [--build-hash ] + * + * INTEGRITY vs PROVENANCE. Without `--build-hash` this proves only that nothing was edited + * after vendoring: a consistent forgery — file and digests changed together — is easy to + * produce, and the build hash inside the copy cannot detect a copy that was built with it. + * Pass the hash eXeLearning published for the release you meant to vendor, obtained out of + * band, and the check becomes one of provenance. + * + * Copyright (C) 2026 eXeLearning Team + * + * Dual-licensed so this ONE file can ship inside eXeLearning (AGPL-3.0-or-later) + * and inside the GPL-3.0-or-later host plugins (mod_exelearning) without either + * project relicensing it. GPLv3 s13 and AGPLv3 s13 already permit COMBINING the + * two, but combining never relicenses a file: only the copyright holder can offer + * it under both, which is what this grant does. Keep this notice in every mirror. + * + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-3.0-or-later + */ +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +export const MANIFEST_FILE = 'exe-external-media.manifest.json'; + +/** ADR-0018. Checked on the received bytes, because that is where it has to be. */ +export const EXPECTED_GRANT = 'SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-3.0-or-later'; + +const sha256 = contents => createHash('sha256').update(contents, 'utf8').digest('hex'); + +/** + * @param {string} dir directory holding the vendored artifacts and their manifest + * @param {{expectBuildHash?: string}} [options] the build hash published for the release + * you meant to vendor; supply it to check provenance, not just integrity + * @returns {string[]} human-readable problems; empty means the copy is good + */ +export function verifyVendoredArtifacts(dir, options = {}) { + const manifestPath = join(dir, MANIFEST_FILE); + if (!existsSync(manifestPath)) { + return [`manifest is missing: expected ${MANIFEST_FILE} in ${dir}`]; + } + + let manifest; + try { + manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + } catch (error) { + return [`manifest is not readable JSON: ${manifestPath} (${error.message})`]; + } + if (!manifest || typeof manifest.files !== 'object' || manifest.files === null) { + return [`manifest has no file list: ${manifestPath}`]; + } + + const problems = []; + + for (const [key, record] of Object.entries(manifest.files)) { + const artifactPath = join(dir, record.path); + if (!existsSync(artifactPath)) { + problems.push(`${key}: artifact is missing (${record.path})`); + continue; + } + + const contents = readFileSync(artifactPath, 'utf8'); + if (sha256(contents) !== record.sha256) { + problems.push( + `${key}: sha256 mismatch — ${record.path} was modified after it was vendored. ` + + 'Change eXeLearning core and re-vendor; a local edit here is invisible upstream.', + ); + } + if (typeof record.bytes === 'number' && statSync(artifactPath).size !== record.bytes) { + problems.push(`${key}: size mismatch — manifest says ${record.bytes}`); + } + if (!contents.includes(EXPECTED_GRANT)) { + problems.push(`${key}: the dual-licence grant is missing from ${record.path}`); + } + } + + // The digest list is itself covered, so editing a file and its digest together does + // not produce a manifest that agrees with itself. + const recomputed = sha256( + Object.keys(manifest.files) + .sort() + .map(key => `${key}:${manifest.files[key].sha256}`) + .join('\n'), + ); + if (manifest.buildHash !== recomputed) { + problems.push('buildHash does not match the manifest file list — the manifest was edited by hand'); + } + + if (options.expectBuildHash && manifest.buildHash !== options.expectBuildHash) { + problems.push( + `buildHash is not the build you expected — vendored ${manifest.buildHash}, ` + + `expected ${options.expectBuildHash}`, + ); + } + + return problems; +} + +/* c8 ignore start -- CLI wrapper; the behaviour above is what is tested */ +if (process.argv[1] && import.meta.url.endsWith(process.argv[1].split('/').pop())) { + const args = process.argv.slice(2); + const hashIndex = args.indexOf('--build-hash'); + const expectBuildHash = hashIndex !== -1 ? args[hashIndex + 1] : undefined; + const dir = args.find(a => !a.startsWith('--') && a !== expectBuildHash) ?? process.cwd(); + + const problems = verifyVendoredArtifacts(dir, { expectBuildHash }); + if (problems.length) { + console.error(`[exe-external-media] ${dir} did NOT verify:`); + for (const problem of problems) console.error(` - ${problem}`); + process.exit(1); + } + console.log(`[exe-external-media] ${dir} verified.`); +} +/* c8 ignore stop */ diff --git a/src/embed/relay-host.ts b/src/embed/relay-host.ts index 4861662..3850775 100644 --- a/src/embed/relay-host.ts +++ b/src/embed/relay-host.ts @@ -4,11 +4,12 @@ * * It overlays real YouTube/Vimeo/Dailymotion/PDF players over the geometry * placeholders the shim reports from inside the opaque iframe. - * `exe_embed_relay.js` attaches `window.exeEmbedRelay`. + * The vendored host bundle attaches `window.exeExternalMediaHost` plus the + * `window.exeEmbedRelay` facade this module drives. * The shim itself is NOT imported here — the PHP ContentController inlines it * into the served package so it runs inside the opaque iframe. */ -import './exe_embed_relay.js' +import './exe_external_media/exe-external-media-host.min.js' interface RelayInstance { clear?: () => void @@ -24,8 +25,17 @@ interface EmbedRelayApi { init: (config: EmbedRelayConfig) => RelayInstance } +interface AttachedMediaHost { + detach: () => void +} + +interface ExternalMediaHostApi { + attachMedia: (iframe: HTMLIFrameElement) => AttachedMediaHost +} + interface BridgeGlobals { exeEmbedRelay?: EmbedRelayApi + exeExternalMediaHost?: ExternalMediaHostApi } /** The bridge globals attached to `window` by the imported mirror script. */ @@ -51,6 +61,32 @@ export function startRelay(config: EmbedRelayConfig = { mode: 'open' }): void { } } +/** One attachment per content iframe; the media host owns the pairing, not the page. */ +const mediaHosts = new WeakMap() + +/** + * Attach the MEDIA half to a content iframe. + * + * Separate from `startRelay` because the two halves are adopted separately (ADR-0024): + * the relay promotes declarative embeds it finds by scanning, while this answers an iDevice + * that asks the host to open and drive a video over a private port. A viewer with only the + * relay looks completely healthy — embeds are promoted, nothing errors — while every bridged + * video in the package is dead, because nobody replies to the handshake. + * + * Memoised per iframe rather than per page: the viewer can mount more than one, and each + * needs its own session. + * @param iframe The opaque content iframe to serve videos for. + */ +export function startMedia(iframe: HTMLIFrameElement): void { + if (mediaHosts.has(iframe)) { + return + } + const api = globals().exeExternalMediaHost + if (api) { + mediaHosts.set(iframe, api.attachMedia(iframe)) + } +} + /** * Ask the shim inside the opaque iframe to (re)report its embed placeholders. * @param iframe The opaque content iframe to ping. diff --git a/src/types/shims.d.ts b/src/types/shims.d.ts index 7ee748d..f37a47c 100644 --- a/src/types/shims.d.ts +++ b/src/types/shims.d.ts @@ -7,5 +7,5 @@ declare module '*.vue' { // The eXe-core embed/media bridge mirrors are plain-JS, side-effect modules // (they attach window.exeEmbedRelay). We import them only // for their side effects and use the window globals via typed casts. -declare module '*/exe_embed_relay.js' -declare module '*/exe_embed_shim.js' +declare module '*/exe-external-media-host.min.js' +declare module '*/exe-external-media-child.min.js' diff --git a/src/viewer/ElpxViewer.vue b/src/viewer/ElpxViewer.vue index 5c94661..8fab670 100644 --- a/src/viewer/ElpxViewer.vue +++ b/src/viewer/ElpxViewer.vue @@ -46,7 +46,7 @@ import { ViewerSession } from '../elpx/viewer-session' import { ensureRuntimeWorker, registerSession, unregisterSession, type RuntimeWorker } from '../elpx/service-worker-client' import { createAssetIframe, createContentIframe, createPackageIframe } from '../elpx/iframe-renderer' import { ASSET_PREFIX, CONTENT_PREFIX } from '../elpx/paths' -import { clearOverlays, type EmbedRelayConfig, pingEmbeds, reflowOverlays, startRelay } from '../embed/relay-host' +import { clearOverlays, type EmbedRelayConfig, pingEmbeds, reflowOverlays, startMedia, startRelay } from '../embed/relay-host' /** * Reads a server-provided initial-state value, falling back on any error so the @@ -239,6 +239,10 @@ export default defineComponent({ pingEmbeds(iframe) }) slot.appendChild(iframe) + // Attached as soon as the frame has a window, and deliberately NOT inside the + // `load` handler above: the content announces itself while it runs, so a host + // that starts listening only after `load` can miss the one hello it sends. + startMedia(iframe) this.iframe = iframe this.relayActive = true window.addEventListener('resize', this.onResize) 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 index 8043e0e..5ea0451 100644 --- a/tests/Unit/Service/IframeSandboxTest.php +++ b/tests/Unit/Service/IframeSandboxTest.php @@ -99,4 +99,30 @@ public function testProviderWhitelistIsLowercaseAndDeduped(): void { 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/js/exe-embed-relay.test.ts b/tests/js/exe-embed-relay.test.ts deleted file mode 100644 index 898b342..0000000 --- a/tests/js/exe-embed-relay.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { beforeAll, beforeEach, describe, expect, it } from 'vitest' -// Side-effect import: the mirror attaches its API on window.exeEmbedRelay, -// exactly as relay-host.ts loads it in the browser. This exercises the raw -// eXe-core relay mirror (src/embed/exe_embed_relay.js) directly, not the -// relay-host.ts wrapper (which mocks the bridge). -import '../../src/embed/exe_embed_relay.js' - -interface RelayInstance { - onMessage: (event: { source: unknown; data: unknown }) => void - checkDrift: () => number - reflow: () => void - dispose: () => void -} - -interface EmbedRelayApi { - createRelay: (config: { mode: string }) => RelayInstance -} - -const relay = (window as unknown as { exeEmbedRelay: EmbedRelayApi }).exeEmbedRelay - -/** A full DOMRect from a box, so it can be assigned to getBoundingClientRect under strict TS. */ -function fakeRect(left: number, top: number, width: number, height: number): DOMRect { - return { - left, - top, - width, - height, - right: left + width, - bottom: top + height, - x: left, - y: top, - toJSON: () => ({}), - } -} - -describe('exe_embed_relay checkDrift (mirror of eXe core)', () => { - let iframe: HTMLIFrameElement - - beforeAll(() => { - // The relay overlays a real