Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ protocol, the iframe boot HTML pattern). Remove everything Drive-specific.
- **Do not add a non-Nextcloud backend.** Anything server-side runs as a
Nextcloud controller, service, or preview provider. CSRF protection,
authentication and permissions go through Nextcloud's APIs.
- **Use `@nextcloud/viewer`, `@nextcloud/files`, `@nextcloud/axios`,
`@nextcloud/router`, `@nextcloud/l10n`**. Do not introduce React, Angular,
- **Use `@nextcloud/files`, `@nextcloud/axios`, `@nextcloud/router`,
`@nextcloud/l10n`**. Do not introduce React, Angular,
or alternative HTTP clients.
- **Service Worker scope must stay narrow.** Only
`/apps/exelearning/runtime/` may be intercepted. The SW must never see
Expand Down Expand Up @@ -109,7 +109,7 @@ that matches before starting:
| Skill | When |
|---|---|
| `architecture-records` | Writing or reviewing an ADR or a change document |
| `nextcloud-app` | Touching `lib/` — controllers, services, DI, routes, preview |
| `nextcloud-app-development` | Touching `lib/` — controllers, services, DI, routes, preview |
| `elpx-package-safety` | Touching ZIP entry handling, entry-path validation or the Service Worker |
| `testing` | Adding or fixing tests in `tests/js/` or `tests/Unit/` |

Expand Down Expand Up @@ -147,13 +147,16 @@ seen them pass.

## Where to look

- `src/main.ts` — registers the Viewer handler and Files actions.
- `src/viewer/ElpxViewer.vue` — the Viewer modal component.
- `src/main.ts` — registers the Files actions and New-menu entry.
- `src/view/*` — the full-page view (`ElpxViewPage.vue`) with its preview
and embedded-editor (`EditorEmbed.vue`) modes.
- `src/viewer/ElpxViewer.vue` — the package preview component.
- `src/elpx/*` — pure-TS extraction, validation, session, SW client, paths.
- `src/files/*` — Files-app integration: MIME helpers and actions.
- `src/editor/*` — optional editor scaffold using the upstream embedding
protocol; safe to ignore unless touching the editor.
- `js/exelearning-sw.js` — the Service Worker itself, hand-written.
- `src/sw/exelearning-sw.js` — the Service Worker itself, hand-written and
served as-is by `SwController`.
- `lib/AppInfo/Application.php` — app bootstrap, init script, preview
provider registration.
- `lib/Controller/*` — HTTP boundary, one controller per concern.
Expand Down
11 changes: 7 additions & 4 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ elpx/package-validator.ts → require index.html
↓
elpx/viewer-session.ts → session id + per-entry map
↓
elpx/service-worker-client.ts → POST session into js/exelearning-sw.js
elpx/service-worker-client.ts → POST session into src/sw/exelearning-sw.js
↓
elpx/iframe-renderer.ts → sandboxed iframe at
/apps/exelearning/runtime/{session}/index.html
Expand Down Expand Up @@ -202,16 +202,19 @@ appear in the Files row menu.
Save flow:

1. The editor iframe posts `SAVE_FILE` with the new bytes.
2. `editor-page.ts` POSTs them to `/apps/exelearning/editor/save` with the
open-file `If-Match` ETag.
2. `EditorEmbed.vue` POSTs them to `/apps/exelearning/editor/save` with the
open-file `If-Match` ETag. The Save button and Ctrl/Cmd+S inside the
editor both go through this path.
3. The PHP controller refuses the write if the file changed on disk
(`HTTP 412`).

## Security model

- `.elpx` HTML never runs in the parent Nextcloud window.
- The iframe uses
`sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads"`.
`sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads allow-popups-to-escape-sandbox"`.
- The server-side asset fallback refuses top-level navigations
(`Sec-Fetch-Dest: document`), so package HTML only renders framed.
- The Service Worker only intercepts URLs under
`/apps/exelearning/runtime/`.
- All ZIP paths are normalized; `..`, absolute paths and NUL-tainted entries
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ up: check-docker
echo "ERROR: js/$(APP_NAME)-main.mjs missing after build."; exit 1; \
fi
@# Make sure the optional eXeLearning static editor is present so the
@# /apps/exelearning/editor route works inside the container. Skip the
@# editor mode of /apps/exelearning/view works in the container. Skip the
@# download when js/editor/index.html already exists; refresh the
@# bundle explicitly with `make download-editor` (optionally pinning
@# EXELEARNING_EDITOR_REF=vX.Y.Z) when a new upstream tag ships.
Expand Down
2 changes: 1 addition & 1 deletion appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
'name' => 'asset#fetch',
'url' => '/asset/{sessionId}/{path}',
'verb' => 'GET',
'requirements' => ['path' => '.+'],
'requirements' => ['sessionId' => '\d+', 'path' => '.+'],
],
[
'name' => 'thumbnail#byFileId',
Expand Down
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"$schema": "https://biomejs.dev/schemas/2.5.14/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
Expand Down
3 changes: 0 additions & 3 deletions codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,6 @@ ignore:
- "tests/**/*"
- "vendor/**/*"
- "src/main.ts"
- "src/editor/editor-frame.ts"
- "src/elpx/elpx-loader.ts"
- "src/elpx/service-worker-client.ts"
- "src/files/actions.ts"
- "src/files/new-menu.ts"
- "src/sw/**/*"
Expand Down
6 changes: 3 additions & 3 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,9 @@ public function register(IRegistrationContext $context): void {
}

public function boot(IBootContext $context): void {
// Register the Viewer handler as an init script. Init scripts are
// emitted by the server in <head>, so the handler is available before
// the Viewer app probes for MIME associations.
// Register the Files actions as an init script. Init scripts are
// emitted by the server in <head>, so they exist before the Files app
// collects actions.
unset($context);
Util::addInitScript(self::APP_ID, 'exelearning-main');
}
Expand Down
2 changes: 1 addition & 1 deletion src/files/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ const downloadAction: IFileAction = {
*/
export function registerFileActions(): void {
registerFileAction(viewAction) // default — opens /apps/exelearning/view
registerFileAction(editAction) // kebab — opens /apps/exelearning/editor
registerFileAction(editAction) // kebab — opens /apps/exelearning/view?mode=editor
registerFileAction(downloadAction) // kebab — native download
registerFileAction(openAsExeLearningAction) // kebab on plain .zip
}
8 changes: 4 additions & 4 deletions src/sw/exelearning-sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* Sessions are kept only in this worker's memory. Closing the tab (and the
* subsequent SW termination) drops them. The page is the source of truth.
*
* This file is shipped as-is — not bundled by webpack — and served by
* This file is shipped as-is — not bundled by Vite — and served by
* SwController so the response can set Content-Type and Service-Worker-Allowed.
*/

Expand Down Expand Up @@ -64,8 +64,8 @@ self.addEventListener('message', (event) => {

/**
* Stores a new session in the in-memory map. Files arrive as a list of
* `{ path, mime, bytes }`; the path is normalised here so request matching
* never has to deal with `..`/`.` segments or backslashes.
* `{ path, mime, bytes }`; non-canonical paths are rejected here, so request
* matching never has to deal with `..`/`.` segments or backslashes.
* @param {{ sessionId: string, files: Array<{ path: string, mime?: string, bytes: ArrayBuffer | null }>, indexEntry?: string, filename?: string }} data
* `EXELEARNING_REGISTER_SESSION` message payload from the page.
*/
Expand Down Expand Up @@ -178,7 +178,7 @@ function safeDecode(value) {
/**
* SW-side mirror of `normalizeEntryPath` from src/elpx/paths.ts. Kept
* inline because the SW must not import from the bundled application
* code (it is loaded out-of-band by the browser, not by webpack).
* code (it is loaded out-of-band by the browser, not by Vite).
*
* Validates and returns the input unchanged, or null. A path is accepted
* only when it is non-empty, free of NUL bytes and backslashes, and made
Expand Down
2 changes: 1 addition & 1 deletion src/viewer/ElpxViewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export default defineComponent({
name: 'ElpxViewer',
components: { ViewerError },
props: {
// @nextcloud/viewer passes these for any registered handler.
// File identity, passed by ElpxViewPage.
filename: { type: String, default: '' },
basename: { type: String, default: '' },
source: { type: String, default: '' },
Expand Down
128 changes: 128 additions & 0 deletions tests/js/editor-frame.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { EditorFrame } from '../../src/editor/editor-frame'

let container: HTMLElement
let frame: EditorFrame

function iframeWindow(): Window {
const iframe = container.querySelector('iframe')
if (!iframe?.contentWindow) throw new Error('iframe not attached')
return iframe.contentWindow
}

/** Delivers a message to the page as if the editor iframe had posted it. */
function fromEditor(data: unknown, source: Window | null = iframeWindow()): void {
window.dispatchEvent(new MessageEvent('message', { data, source }))
}

/** Captures what the page posts into the editor iframe. */
function spyOnPosts() {
return vi.spyOn(iframeWindow(), 'postMessage').mockImplementation(() => undefined)
}

async function readyFrame(): Promise<void> {
const loading = frame.load()
fromEditor({ type: 'EXELEARNING_READY' })
await loading
}

beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
frame = new EditorFrame(container, { editorIframeUrl: 'about:blank' })
})

afterEach(() => {
frame.destroy()
container.remove()
vi.useRealTimers()
})

describe('EditorFrame', () => {
it('refuses to talk to the editor before it reports ready', async () => {
await expect(frame.requestSave()).rejects.toThrow('not ready')
})

it('ignores messages that do not come from its own iframe', async () => {
vi.useFakeTimers()
const loading = frame.load()
fromEditor({ type: 'EXELEARNING_READY' }, window)
vi.advanceTimersByTime(60_000)

await expect(loading).rejects.toThrow('Timed out waiting for EXELEARNING_READY')
})

it('resolves a save only with the reply to its own request id', async () => {
await readyFrame()
const posts = spyOnPosts()

const saving = frame.requestSave()
const requestId = (posts.mock.calls[0][0] as { requestId: string }).requestId
fromEditor({ type: 'SAVE_FILE', requestId: 'someone-else', bytes: new Uint8Array([9]) })
fromEditor({ type: 'SAVE_FILE', requestId, bytes: new Uint8Array([1, 2]), filename: 'a.elpx' })

const saved = await saving
expect(new Uint8Array(saved.bytes)).toEqual(new Uint8Array([1, 2]))
expect(saved.filename).toBe('a.elpx')
})

it('rejects a save when the editor reports an error', async () => {
await readyFrame()
const posts = spyOnPosts()

const saving = frame.requestSave()
const requestId = (posts.mock.calls[0][0] as { requestId: string }).requestId
fromEditor({ type: 'REQUEST_SAVE_ERROR', requestId, error: 'disk full' })

await expect(saving).rejects.toThrow('disk full')
})

it('transfers the package bytes when opening a file', async () => {
await readyFrame()
const posts = spyOnPosts()
const bytes = new ArrayBuffer(4)

const opening = frame.openFile({ bytes, filename: 'lesson.elpx' })
const [message, , transfer] = posts.mock.calls[0] as unknown as [{ requestId: string }, string, Transferable[]]
expect(transfer).toEqual([bytes])
fromEditor({ type: 'OPEN_FILE_SUCCESS', requestId: message.requestId })

await expect(opening).resolves.toBeUndefined()
})

it('times out a save the editor never answers', async () => {
await readyFrame()
spyOnPosts()
vi.useFakeTimers()

const saving = frame.requestSave()
vi.advanceTimersByTime(60_000)

await expect(saving).rejects.toThrow('Timed out waiting for the eXeLearning editor')
})

it('reports an error type without a message and a detached iframe', async () => {
await readyFrame()
const posts = spyOnPosts()

const opening = frame.openFile({ bytes: new ArrayBuffer(1), filename: 'x.elpx' })
const requestId = (posts.mock.calls[0][0] as { requestId: string }).requestId
fromEditor({ type: 'OPEN_FILE_ERROR', requestId })
await expect(opening).rejects.toThrow('OPEN_FILE_ERROR')

container.querySelector('iframe')?.remove()
await expect(frame.requestSave()).rejects.toThrow('not available')
})

it('forwards unsolicited editor messages to subscribers until they unsubscribe', () => {
const seen: string[] = []
const unsubscribe = frame.onMessage((message) => seen.push(message.type))

fromEditor({ type: 'REQUEST_SAVE' })
unsubscribe()
fromEditor({ type: 'REQUEST_SAVE' })
fromEditor('not an editor message')

expect(seen).toEqual(['REQUEST_SAVE'])
})
})
61 changes: 61 additions & 0 deletions tests/js/elpx-loader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

const get = vi.fn()
vi.mock('@nextcloud/axios', () => ({ default: { get } }))
vi.mock('@nextcloud/router', () => ({
generateUrl: (path: string, params?: Record<string, unknown>) =>
path.replace(/\{(\w+)\}/g, (_, key: string) => String(params?.[key])),
}))

const { loadElpx } = await import('../../src/elpx/elpx-loader')

function respond(headers: Record<string, string> = {}) {
get.mockResolvedValueOnce({ data: new ArrayBuffer(3), headers })
}

describe('loadElpx', () => {
beforeEach(() => get.mockReset())

it('requires a file id or a path', async () => {
await expect(loadElpx({})).rejects.toThrow('Either fileId or path')
await expect(loadElpx({ path: '' })).rejects.toThrow('Either fileId or path')
})

it('loads by file id and reads filename and etag from the headers', async () => {
respond({ 'content-disposition': 'attachment; filename*=UTF-8\'\'Lecci%C3%B3n%201.elpx', etag: '"abc"' })

const loaded = await loadElpx({ fileId: 7 })

expect(get).toHaveBeenCalledWith('/apps/exelearning/package/by-file-id/7', { responseType: 'arraybuffer' })
expect(loaded).toMatchObject({ filename: 'Lección 1.elpx', etag: '"abc"' })
})

it('loads by path and accepts a plain quoted filename', async () => {
respond({ 'content-disposition': 'attachment; filename="plain.elpx"' })

const loaded = await loadElpx({ path: 'Docs/x.elpx' })

expect(get).toHaveBeenCalledWith('/apps/exelearning/package/by-path', {
responseType: 'arraybuffer',
params: { path: 'Docs/x.elpx' },
})
expect(loaded.filename).toBe('plain.elpx')
expect(loaded).not.toHaveProperty('etag')
})

it('keeps an undecodable filename verbatim', async () => {
respond({ 'content-disposition': 'attachment; filename*=UTF-8\'\'bad%E0.elpx' })

expect((await loadElpx({ fileId: 1 })).filename).toBe('bad%E0.elpx')
})

it('derives a filename when the server sends no Content-Disposition', async () => {
respond()
respond()
respond({ 'content-disposition': 'inline' })

expect((await loadElpx({ path: 'Docs/lesson.elpx' })).filename).toBe('lesson.elpx')
expect((await loadElpx({ path: 'top.elpx' })).filename).toBe('top.elpx')
expect((await loadElpx({ fileId: 9 })).filename).toBe('package-9.elpx')
})
})
Loading
Loading