Conversation
…rence) 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.
Contributor
Preview this PR in the Nextcloud PlaygroundA fresh Nextcloud boots in your browser with this branch's eXeLearning editor: |
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.
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.
…SP + 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).
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.
…ilder
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).
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).
Add IframeSandbox::embedMode() (strict default, open opt-in via
EXELEARNING_EMBED_OPEN) and pass {mode, whitelist} to the relay via initial
state, so the published viewer overlays only maintained providers unless opened
up. relay-host.startRelay now accepts the config (defaults to open).
erseco
marked this pull request as draft
July 6, 2026 23:08
The cookieless #[PublicPage] content route has no session, so IRootFolder::getById
resolved nothing and every /content/{token}/... returned 404. Bind the user id into
the token and resolve via getForUserById(uid, fileId) at serve time, which mounts
the user's storage and re-checks the read permission.
…he Playground The php-wasm Playground has no real HTTP server (a Service Worker can't serve an opaque iframe), so its published-viewer demo needs the same-origin path. The nextcloud-playground blueprint schema can only reach PHP via config:app:set, so wire IframeSandbox's injected envReader (in Application.php, keeping IframeSandbox OCP-free) to fall back to a Nextcloud app-config value when the process env is unset — process env still wins on real hosts. blueprint.json sets it with a setConfig step (loud DEV-ONLY comment). ApplicationTest (7 tests) proves the precedence + secure-by-default.
Implement the eXeLearning editor-preview serving contract v2 as a Nextcloud-native host: an authless, cookieless capability URL that serves untrusted author HTML/JS from an opaque origin, plus an authenticated, owner-scoped management API. Protocol logic lives in OCP-free seams under lib/Service/Preview so every rule is unit-testable without a server: - PreviewPolicy: single source of truth for the sandbox-first CSP (byte-identical to core previewCspHeader), the scriptable-document set, the Permissions-Policy, served MIME resolution and traversal-safe path normalization. - PreviewSessionStore: file-backed three-layer store (generated documents, session assets, fixed refs) with atomic revisions (staging + pointer swap), asset immutability, per-session/per-user/global budgets with LRU eviction, and a 30-min idle TTL. - FixedResourceManifest: exact-key lookup into the installed editor distribution's bundles/preview-fixed-resources.json with distribution-root containment; absent manifest disables the layer (422, client demotes). - PreviewServer: serving HTTP policy (hardening headers on every response incl. 404, tiered Cache-Control, ETag/304, single-range 206/416, sandbox CSP on every scriptable type from any layer). - PreviewSessionApi: management HTTP policy (ownership gate, 409/422/413 wire bodies, protocol-version negotiation). Thin Nextcloud adapters on top: PreviewController (#[PublicPage] serving), PreviewSessionController (#[NoAdminRequired], CSRF on, mirroring editor#save), and PreviewCleanupJob (TimedJob sweeping expired sessions). Routes and the background job are registered; the store root is resolved from the Nextcloud data directory (the only OCP seam). The editor is intentionally not wired to select the http transport yet.
Cover the serving-contract v2 seams without a Nextcloud server: - PreviewPolicyTest: CSP byte-identity with core, scriptable classification, MIME resolution, path normalization (literal/encoded traversal, backslash, NUL), id/key validation. - PreviewSessionStoreTest: lifecycle, per-user LRU cap, asset immutability (original bytes keep serving), atomic revision ordering, the full validation ladder (409/400/422/413), incremental deltas/deletes, three-layer resolution, and idle-TTL drop-on-access + sweep. - FixedResourceManifestTest: manifest lookup, containment, and graceful disable when the manifest is absent/malformed. - PreviewServerTest and PreviewSessionApiTest: header/CSP/cache/ETag/Range policy and the management status/body mapping. - PreviewContractConformanceTest: replays tests/fixtures/preview-contract/ vectors.json (vendored verbatim from eXe core) against the API + server, porting the harness interpretation so protocol semantics stay aligned with every other host.
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.
Sync the byte-synced eXe-core embed bridge mirrors with core commit 054abfd0 (external-media relay stayed frozen when the exported page's nav drawer / a class-flip reflow moved the content iframe with no scroll/resize event). exe_embed_shim.js init(): report(force) with a JSON dirty-check (force on initial run, load and parent 'request' pings; observer-driven reports skip identical geometry so attribute-noisy pages cannot spam the parent). The MutationObserver now also watches attributes (class/style/hidden/open), and transitionend/animationend + a documentElement/body ResizeObserver feed the reflow schedule. exe_embed_relay.js: positionOverlay records entry.lastRect; a new checkDrift() re-pins any overlay whose content-iframe box moved without an event and returns the moved count; it is exposed on the relay and run from a 300ms interval in init(). Changed regions are byte-identical to core (only the export wrapper differs). Port core's checkDrift unit test (tests/js/exe-embed-relay.test.ts) driving the raw mirror via createRelay, kept deterministic/offline by disabling happy-dom child-frame navigation and file loading.
Authenticated disk-fill DoS: publishRevision wrote each revision's unique content-addressed document blobs under the session tree but never pruned superseded revisions, while the per-session (200 MiB) and global (2 GiB) budgets count only the ACTIVE revision's documentBytes + assetBytes. An authenticated user looping publishRevision with ~199 MiB of unique-content documents per revision passed every budget check yet left each revision's bytes on disk, so N revisions = N x ~199 MiB physical, unbounded — filling the Nextcloud data volume. Fix: after the atomic `current` pointer swap, prune to a single active revision — delete superseded revision manifests and any document blob no longer referenced by the active revision. Pruning runs under the same exclusive publish flock (single writer) and AFTER the swap, so new readers already resolve the new revision; a GET still pinned to the just-superseded revision may 404 and re-sync (the designed recovery). Assets live in a separate directory and are never pruned — they are shared and immutable across revisions. A failed publish returns before any blob is written (validation precedes the publish block), so it leaves no staging behind. Physical document disk is now bounded by the active-revision byte budget. TDD: new store test publishes several large unique-content revisions and asserts only the active revision's manifest + single blob remain, the asset survives every revision, and on-disk bytes stay bounded (not N x docSize).
…adyStored storeAssets() reported a blob-write failure (disk full, unwritable assets dir, link/rename failure) as `alreadyStored`. Per the contract's recovery flow `alreadyStored` means "the server already holds these bytes", so the client marked the key uploaded — the asset then 404s forever and the missing-assets recovery loop could never repair it. Distinguish the two false returns of writeBlobAtomic(): when the target now exists the key appeared concurrently (bytes present → alreadyStored); when it does not, the write genuinely failed (bytes absent → rejected with reason 'write-failed'). A rejected key is left un-uploaded, so a later revision referencing it returns 422 missing-assets and the client re-uploads it. TDD: new store test forces a deterministic, root-independent write failure (replaces assets/ with a regular file → ENOTDIR) and asserts the key is rejected 'write-failed' (not stored/alreadyStored), the blob is absent, and a revision referencing it returns 422 missing-assets.
Sync exe_embed_relay.js with core: the 300ms checkDrift interval and the window listeners installed by init() were left running for the page lifetime with no teardown, and a second init() on the same relay stacked a duplicate interval + duplicate listeners. createRelay() now tracks driftTimer + started; dispose() runs clear() then clears the drift interval and removes the message/resize/scroll/load listeners (idempotent, safe before init or called twice), exposed on the relay; init() early-returns when already started and stores the interval handle in driftTimer. Changed region is byte-identical to core (only the export wrapper differs). Port core's dispose unit test into tests/js/exe-embed-relay.test.ts (tears down overlays like clear(), safe before init and when called twice).
Inject a previewHttp block into window.__EXE_EMBEDDING_CONFIG__ so the embedded editor can drive the HTTP editor-preview transport (serving contract v2): protocolVersion 2, a management base URL, a serving base URL, and the current Nextcloud CSRF token as the requesttoken header. 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 @nextcloud/router generateUrl on the client); the serving base is derived by generating the bare capability-root URL for a placeholder id and stripping the id segment. The requesttoken is the same encrypted value the standard template layer exposes as data-requesttoken (CsrfTokenManager::getToken()->getEncryptedValue()), which the management routes require because they keep CSRF on. The bundled v4.0.2 editor predates the HTTP preview client and simply ignores the block; a capable build activates it with no server change.
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.
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.
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.
…iptions - 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.
Seven files referenced ADR-0018, ADR-0021 and ADR-0024 in comments, an ESLint rule, a CI step and two tests. Those records live in exelearning/exelearning, which replaced its global ADR counter with tracking-number identifiers: they are now ADR-2199-09, ADR-2199-12 and ADR-2199-15. That stale form is what fails the decision-record check this branch inherits from main — the validator rejects the retired four-digit shape anywhere in the tree, and it was right to: a bare `ADR-NNNN` in a comment never said which repository owned the record, which is exactly the ambiguity that made these references rot unnoticed. Each now names the repository. node tools/architecture-records.mts check OK — 2 records, 0 changes npm run typecheck exit 0 npm test 8 files / 126 tests npm run lint exit 0
Keep the ESLint 10 flat config from main and port the vendored external-media ignore into eslint.config.mjs. Drop .eslintrc.cjs.
ESLint 10's jsdoc/escape-inline-tags treats a bare @nextcloud as a tag.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #68 +/- ##
============================================
+ Coverage 93.75% 95.12% +1.36%
- Complexity 150 388 +238
============================================
Files 22 33 +11
Lines 657 1271 +614
Branches 54 57 +3
============================================
+ Hits 616 1209 +593
- Misses 34 53 +19
- Partials 7 9 +2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this does
Renders untrusted
.elpxcontent in an opaque origin — a sandbox withoutallow-same-origin— so author scripts cannot reach the Nextcloud session, andgives the embedded editor a place to publish its opaque preview.
The preview contract
The editor sends 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.
POST /api/preview-session·DELETE /api/preview-session/{previewId}GET /preview/{previewId}/{path}This app previously emitted a preview contract the editor no longer reads, which
left the opaque preview unreachable: it failed closed and silently stayed
filtered. Rebuilding the editor from its current branch confirmed which contract
it reads before any code moved.
Bounds kept, because one Nextcloud instance serves many users off one
filesystem: 30-min idle TTL, 4 snapshots/user and a 2 GiB global budget, both
LRU-evicting. Archives are vetted entry by entry before anything is written, and
the zip-bomb cap is measured on real decompressed bytes.
External video
An opaque origin fails YouTube's and Vimeo's embedder check (Error 153). A shim
inside the iframe demotes provider iframes to geometry placeholders and a relay
on the trusted parent overlays the real player over each one.
Also drops the parent half of the interactive-video media bridge: its child
script is not distributed, so it could never attach to anything.
Docs
docs/preview-serving-contract.md.