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
1 change: 0 additions & 1 deletion codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ ignore:
- "vendor/**/*"
- "src/main.ts"
- "src/editor/editor-frame.ts"
- "src/editor/editor-page.ts"
- "src/elpx/elpx-loader.ts"
- "src/elpx/service-worker-client.ts"
- "src/files/actions.ts"
Expand Down
85 changes: 14 additions & 71 deletions lib/Controller/EditorController.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,26 +12,20 @@
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\AppFramework\Http\DataDisplayResponse;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\AppFramework\Http\RedirectResponse;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUserSession;
use OCP\Util;

/**
* Hosts the optional static eXeLearning editor inside a Nextcloud page. The
* editor is only available when its bundle has been downloaded to
* `js/editor/` (run `make download-editor`).
*
* The page itself is a thin shell — the editor is loaded into an iframe via
* `editor-page.ts` using the same postMessage protocol documented by the
* upstream eXeLearning project.
* Backend for the optional static eXeLearning editor, which is only
* available when its bundle has been downloaded to `js/editor/` (run
* `make download-editor`). The view page embeds it through `iframe()` and
* writes the exported package back through `save()`.
*/
class EditorController extends Controller {
public function __construct(
Expand All @@ -41,76 +35,25 @@ public function __construct(
private readonly ElpxPackageService $packageService,
private readonly LegacyFileMigrationService $legacyFileMigration,
private readonly EditorHtmlService $editorHtml,
private readonly IInitialState $initialState,
private readonly IURLGenerator $urlGenerator,
) {
parent::__construct($appName, $request);
}

/**
* Legacy entry point: the editor now lives inside the view page. Kept as
* a redirect so old bookmarks and links still open the file.
*/
#[NoAdminRequired]
#[NoCSRFRequired]
public function index(?int $fileId = null, ?string $path = null): TemplateResponse|DataResponse {
$user = $this->userSession->getUser();
if ($user === null) {
return new DataResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
}

$editorAvailable = is_file(__DIR__ . '/../../js/editor/index.html');

public function index(?int $fileId = null, ?string $path = null): RedirectResponse {
$params = ['mode' => 'editor'];
if ($fileId !== null && $fileId > 0) {
try {
$file = $this->packageService->getForUserById($user->getUID(), $fileId);
$this->initialState->provideInitialState('file', [
'id' => $file->getId(),
'name' => $file->getName(),
'path' => $file->getPath(),
'mtime' => $file->getMTime(),
'etag' => $file->getEtag(),
'writable' => $file->isUpdateable(),
]);
} catch (NotFoundException|NotPermittedException) {
// fall through: editor still opens, just without a preloaded file
}
$params['fileId'] = $fileId;
} elseif ($path !== null && $path !== '') {
try {
$file = $this->packageService->getForUserByPath($user->getUID(), $path);
$this->initialState->provideInitialState('file', [
'id' => $file->getId(),
'name' => $file->getName(),
'path' => $file->getPath(),
'mtime' => $file->getMTime(),
'etag' => $file->getEtag(),
'writable' => $file->isUpdateable(),
]);
} catch (NotFoundException|NotPermittedException) {
// fall through
}
$params['path'] = $path;
}

// linkTo() returns the right URL whether the app lives at
// /apps/<id>/ or /custom_apps/<id>/ (see apps_paths in config).
$editorBasePath = rtrim($this->urlGenerator->linkTo(Application::APP_ID, ''), '/') . '/js/editor';
// Route URLs always go through Nextcloud's PHP router and live under
// /apps/<id>/ regardless of apps_paths.
$editorIframeUrl = $this->urlGenerator->linkToRoute(Application::APP_ID . '.editor.iframe');

$this->initialState->provideInitialState('editorAvailable', $editorAvailable);
$this->initialState->provideInitialState('editorBasePath', $editorBasePath);
$this->initialState->provideInitialState('editorIframeUrl', $editorIframeUrl);

Util::addScript(Application::APP_ID, 'exelearning-editor');

// RENDER_AS_USER keeps Nextcloud's script + initial-state injection
// working. The template's CSS positions the editor root with
// `position: fixed; inset: 0` so the iframe still uses the whole
// viewport while Nextcloud's chrome stays loaded (but covered).
$response = new TemplateResponse(Application::APP_ID, 'editor', [], TemplateResponse::RENDER_AS_USER);
$csp = new ContentSecurityPolicy();
$csp->addAllowedScriptDomain("'self'");
$csp->addAllowedConnectDomain("'self'");
$csp->addAllowedFrameDomain("'self'");
$response->setContentSecurityPolicy($csp);
return $response;
return new RedirectResponse($this->urlGenerator->linkToRoute(Application::APP_ID . '.view.index', $params));
}

#[NoAdminRequired]
Expand Down
6 changes: 0 additions & 6 deletions lib/Controller/ViewController.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUserSession;
use OCP\Util;

Expand All @@ -38,7 +37,6 @@ public function __construct(
private readonly IUserSession $userSession,
private readonly ElpxPackageService $packageService,
private readonly IInitialState $initialState,
private readonly IURLGenerator $urlGenerator,
) {
parent::__construct($appName, $request);
}
Expand Down Expand Up @@ -80,10 +78,6 @@ public function index(?int $fileId = null, ?string $path = null, ?string $mode =
'editorAvailable',
is_file(__DIR__ . '/../../js/editor/index.html'),
);
$this->initialState->provideInitialState(
'editorIframeUrl',
$this->urlGenerator->linkToRoute(Application::APP_ID . '.editor.iframe'),
);
$this->initialState->provideInitialState(
'initialMode',
$mode === 'editor' ? 'editor' : 'preview',
Expand Down
148 changes: 0 additions & 148 deletions src/editor/editor-page.ts

This file was deleted.

12 changes: 3 additions & 9 deletions src/elpx/elpx-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,20 @@ import { generateUrl } from '@nextcloud/router'
export interface LoadElpxOptions {
fileId?: number
path?: string
signal?: AbortSignal
}

export interface LoadedElpx {
bytes: ArrayBuffer
filename: string
etag?: string
contentLength?: number
}

/**
* Fetches the raw `.elpx` bytes for the current Nextcloud user via
* `PackageController`. One of `fileId` or `path` is required; everything
* else (etag, content-length, filename) is best-effort metadata extracted
* from the response headers.
* @param options Lookup options — fileId or path, optional AbortSignal.
* else (etag, filename) is best-effort metadata extracted from the
* response headers.
* @param options Lookup options — fileId or path.
*/
export async function loadElpx(options: LoadElpxOptions): Promise<LoadedElpx> {
if (options.fileId === undefined && (options.path === undefined || options.path === '')) {
Expand All @@ -39,7 +37,6 @@ export async function loadElpx(options: LoadElpxOptions): Promise<LoadedElpx> {
const response = await axios.get<ArrayBuffer>(url, {
responseType: 'arraybuffer',
...(options.path !== undefined ? { params: { path: options.path } } : {}),
...(options.signal !== undefined ? { signal: options.signal } : {}),
})

const disposition = response.headers['content-disposition']
Expand All @@ -51,9 +48,6 @@ export async function loadElpx(options: LoadElpxOptions): Promise<LoadedElpx> {
bytes: response.data,
filename,
...(typeof etagHeader === 'string' ? { etag: etagHeader } : {}),
...(typeof response.data.byteLength === 'number'
? { contentLength: response.data.byteLength }
: {}),
}
}

Expand Down
19 changes: 1 addition & 18 deletions src/elpx/package-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,11 @@
*/

const ROOT_INDEX_CANDIDATES = ['index.html', 'index.htm']
const DIR_HINTS = ['html/', 'content/', 'libs/', 'theme/', 'idevices/']
// `contentv3.xml`, `contentv2.foo`, `contentv1.bar`, etc., at root.
const LEGACY_MARKER_REGEX = /^contentv\d+(?:\.|$)/

export interface PackageShape {
indexEntry: string | null
hasContentXml: boolean
hasScreenshot: boolean
hintCount: number
legacyMarker: string | null
}

Expand All @@ -36,10 +32,7 @@ export interface PackageValidation {

/**
* Walks the decompressed entry map and reports the shape of an eXeLearning
* project: which root index file (if any) is present, whether helper
* artefacts like `content.xml` / `screenshot.png` are there, how many
* familiar directory hints (`html/`, `idevices/`, …) appear, and which
* (if any) `contentv\d+` file at the archive root marks it as a legacy
* project: which root index file (if any) is present, and which (if any) `contentv\d+` file at the archive root marks it as a legacy
* pre-`.elpx` project.
* @param entries Normalised entry path → bytes from `readPackage()`.
*/
Expand All @@ -51,24 +44,14 @@ export function inspectPackage(entries: ReadonlyMap<string, Uint8Array>): Packag
break
}
}
let hintCount = 0
let legacyMarker: string | null = null
for (const entry of entries.keys()) {
for (const dir of DIR_HINTS) {
if (entry.startsWith(dir)) {
hintCount += 1
break
}
}
if (legacyMarker === null && LEGACY_MARKER_REGEX.test(entry)) {
legacyMarker = entry
}
}
return {
indexEntry,
hasContentXml: entries.has('content.xml'),
hasScreenshot: entries.has('screenshot.png'),
hintCount,
legacyMarker,
}
}
Expand Down
Loading
Loading