diff --git a/lib/Controller/EditorController.php b/lib/Controller/EditorController.php
index 87dadbd..1663315 100644
--- a/lib/Controller/EditorController.php
+++ b/lib/Controller/EditorController.php
@@ -5,6 +5,7 @@
namespace OCA\ExeLearning\Controller;
use OCA\ExeLearning\AppInfo\Application;
+use OCA\ExeLearning\Service\EditorHtmlService;
use OCA\ExeLearning\Service\ElpxPackageService;
use OCA\ExeLearning\Service\LegacyFileMigrationService;
use OCP\AppFramework\Controller;
@@ -39,6 +40,7 @@ public function __construct(
private readonly IUserSession $userSession,
private readonly ElpxPackageService $packageService,
private readonly LegacyFileMigrationService $legacyFileMigration,
+ private readonly EditorHtmlService $editorHtml,
private readonly IInitialState $initialState,
private readonly IURLGenerator $urlGenerator,
) {
@@ -198,35 +200,7 @@ public function iframe(): DataDisplayResponse|DataResponse {
// save. Deriving from `document.baseURI` (which equals the scoped
// `
', $html); + $html = $this->editorHtml->prepare($html, $editorBaseHref); $response = new DataDisplayResponse($html, Http::STATUS_OK, [ 'Content-Type' => 'text/html; charset=utf-8', @@ -252,159 +226,4 @@ public function iframe(): DataDisplayResponse|DataResponse { return $response; } - /** - * Resilience shim injected into the editor
before any editor
- * script runs. The static eXeLearning editor's ResourceFetcher rejects
- * on missing CSS / iDevice resources; under the php-wasm Playground
- * those `files/perm/...` paths 404 (even though they ship in the bundle)
- * and the unhandled rejection aborts the Yjs theme bind, leaving the
- * page blank. mod_exelearning, wp-exelearning and omeka-s-exelearning
- * all ship the same workaround:
- *
- * - swallow 404s on .css / idevices URLs (fetch + jQuery ajax) and
- * return an empty stylesheet so the editor keeps booting;
- * - neutralize preview-sw.js service-worker registration with a full
- * ServiceWorkerRegistration-like stub (a bare `{scope:''}` makes the
- * v4 editor throw on `reg.addEventListener` and aborts the hidden
- * export iframe used for Web/SCORM/ePub export).
- */
- private function resilienceScript(): string {
- return <<<'JS'
-(function () {
- if ("serviceWorker" in navigator) {
- try {
- navigator.serviceWorker.register = function () {
- return Promise.resolve({
- scope: "", installing: null, waiting: null, active: null,
- addEventListener: function () {}, removeEventListener: function () {},
- update: function () { return Promise.resolve(); },
- unregister: function () { return Promise.resolve(true); }
- });
- };
- } catch (e) { void e; }
- }
-
- var originalFetch = window.fetch;
- if (originalFetch) {
- window.fetch = function (input, init) {
- var url = typeof input === "string" ? input : (input && input.url) || "";
- return originalFetch.apply(this, arguments).then(function (response) {
- if (!response.ok && (url.indexOf(".css") !== -1 || url.indexOf("idevices") !== -1)) {
- console.warn("[Nextcloud] Fetch 404 fallback:", url);
- return new Response("/* empty fallback */", { status: 200, headers: { "Content-Type": "text/css" } });
- }
- return response;
- }).catch(function (error) {
- if (url.indexOf(".css") !== -1 || url.indexOf("idevices") !== -1) {
- console.warn("[Nextcloud] Fetch error fallback:", url);
- return new Response("/* empty fallback */", { status: 200, headers: { "Content-Type": "text/css" } });
- }
- throw error;
- });
- };
- }
-
- var patchJQuery = function ($) {
- if (!$ || !$.ajaxTransport) return;
- $.ajaxTransport("+*", function (options) {
- var url = options.url || "";
- if (!(url.indexOf(".css") !== -1 || url.indexOf("idevices") !== -1)) return;
- return {
- send: function (headers, completeCallback) {
- var xhr = new XMLHttpRequest();
- xhr.open(options.type || "GET", url, true);
- xhr.onload = function () {
- if (xhr.status >= 200 && xhr.status < 300) {
- completeCallback(xhr.status, xhr.statusText, { text: xhr.responseText });
- } else {
- console.warn("[Nextcloud] jQuery 404 fallback:", url);
- completeCallback(200, "OK", { text: "/* empty fallback */" });
- }
- };
- xhr.onerror = function () {
- console.warn("[Nextcloud] jQuery error fallback:", url);
- completeCallback(200, "OK", { text: "/* empty fallback */" });
- };
- xhr.send();
- },
- abort: function () {}
- };
- });
- };
- if (window.jQuery) {
- patchJQuery(window.jQuery);
- } else {
- try {
- Object.defineProperty(window, "jQuery", {
- configurable: true,
- set: function (val) {
- Object.defineProperty(window, "jQuery", {
- configurable: true, writable: true, enumerable: true, value: val
- });
- patchJQuery(val);
- },
- get: function () { return undefined; }
- });
- } catch (e) { void e; }
- }
-})();
-JS;
- }
-
- private function bridgeScript(): string {
- return <<<'JS'
-(() => {
- const send = (msg) => { try { window.parent.postMessage(msg, '*'); } catch (e) { void e; } };
- window.addEventListener('keydown', (event) => {
- if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
- event.preventDefault();
- send({ type: 'REQUEST_SAVE', requestId: 'nextcloud-exelearning-shortcut-' + Date.now() });
- }
- }, true);
-
- const waitForReady = () => new Promise((resolve) => {
- const tick = () => {
- const ready = window.eXeLearning && window.eXeLearning.ready;
- if (ready && typeof ready.then === 'function') ready.then(resolve);
- else setTimeout(tick, 50);
- };
- tick();
- });
- waitForReady().then(() => {
- const bridge = window.eXeLearning && window.eXeLearning.app && window.eXeLearning.app.embeddingBridge;
- if (!bridge) return;
- bridge.handleSaveRequest = async function (requestId) {
- const project = this.app.project;
- const yjsBridge = project && project._yjsBridge;
- const documentManager = yjsBridge && yjsBridge.documentManager;
- if (!window.SharedExporters || !documentManager) {
- throw new Error('Exporter unavailable');
- }
- if (typeof documentManager._updateVersionMetadata === 'function') {
- try { await documentManager._updateVersionMetadata(); } catch (_e) { void _e; }
- }
- const exporter = window.SharedExporters.createExporter(
- 'elpx', documentManager,
- yjsBridge.assetCache, yjsBridge.resourceFetcher, yjsBridge.assetManager
- );
- const result = await exporter.export({});
- if (!result || !result.success || !result.data) {
- throw new Error((result && result.error) || 'Export failed');
- }
- const data = result.data;
- const bytes = data instanceof ArrayBuffer
- ? data
- : (ArrayBuffer.isView(data)
- ? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)
- : data);
- this.postToParent({
- type: 'SAVE_FILE', requestId,
- bytes, filename: result.filename || 'project.elpx',
- size: bytes.byteLength,
- });
- };
- });
-})();
-JS;
- }
}
diff --git a/lib/Service/EditorHtmlService.php b/lib/Service/EditorHtmlService.php
new file mode 100644
index 0000000..1bec9db
--- /dev/null
+++ b/lib/Service/EditorHtmlService.php
@@ -0,0 +1,180 @@
+ (object)[
+ 'fileMenu' => true,
+ 'saveButton' => true,
+ 'shareButton' => false,
+ 'userMenu' => true,
+ 'downloadButton' => false,
+ 'helpMenu' => false,
+ ],
+ ], JSON_UNESCAPED_SLASHES);
+
+ $configScript = '';
+
+ $resilienceScript = '';
+ $headInject = '
]*>/i', $html, $match, PREG_OFFSET_CAPTURE)) { + $position = $match[0][1] + strlen($match[0][0]); + $html = substr($html, 0, $position) . $headInject . substr($html, $position); + } + + $bridge = ''; + return str_ireplace('', $bridge . '', $html); + } + + private function resilienceScript(): string { + return <<<'JS' +(function () { + if ("serviceWorker" in navigator) { + try { + navigator.serviceWorker.register = function () { + return Promise.resolve({ + scope: "", installing: null, waiting: null, active: null, + addEventListener: function () {}, removeEventListener: function () {}, + update: function () { return Promise.resolve(); }, + unregister: function () { return Promise.resolve(true); } + }); + }; + } catch (e) { void e; } + } + + var originalFetch = window.fetch; + if (originalFetch) { + window.fetch = function (input, init) { + var url = typeof input === "string" ? input : (input && input.url) || ""; + return originalFetch.apply(this, arguments).then(function (response) { + if (!response.ok && (url.indexOf(".css") !== -1 || url.indexOf("idevices") !== -1)) { + console.warn("[Nextcloud] Fetch 404 fallback:", url); + return new Response("/* empty fallback */", { status: 200, headers: { "Content-Type": "text/css" } }); + } + return response; + }).catch(function (error) { + if (url.indexOf(".css") !== -1 || url.indexOf("idevices") !== -1) { + console.warn("[Nextcloud] Fetch error fallback:", url); + return new Response("/* empty fallback */", { status: 200, headers: { "Content-Type": "text/css" } }); + } + throw error; + }); + }; + } + + var patchJQuery = function ($) { + if (!$ || !$.ajaxTransport) return; + $.ajaxTransport("+*", function (options) { + var url = options.url || ""; + if (!(url.indexOf(".css") !== -1 || url.indexOf("idevices") !== -1)) return; + return { + send: function (headers, completeCallback) { + var xhr = new XMLHttpRequest(); + xhr.open(options.type || "GET", url, true); + xhr.onload = function () { + if (xhr.status >= 200 && xhr.status < 300) { + completeCallback(xhr.status, xhr.statusText, { text: xhr.responseText }); + } else { + console.warn("[Nextcloud] jQuery 404 fallback:", url); + completeCallback(200, "OK", { text: "/* empty fallback */" }); + } + }; + xhr.onerror = function () { + console.warn("[Nextcloud] jQuery error fallback:", url); + completeCallback(200, "OK", { text: "/* empty fallback */" }); + }; + xhr.send(); + }, + abort: function () {} + }; + }); + }; + if (window.jQuery) { + patchJQuery(window.jQuery); + } else { + try { + Object.defineProperty(window, "jQuery", { + configurable: true, + set: function (val) { + Object.defineProperty(window, "jQuery", { + configurable: true, writable: true, enumerable: true, value: val + }); + patchJQuery(val); + }, + get: function () { return undefined; } + }); + } catch (e) { void e; } + } +})(); +JS; + } + + private function bridgeScript(): string { + return <<<'JS' +(() => { + const send = (msg) => { try { window.parent.postMessage(msg, '*'); } catch (e) { void e; } }; + window.addEventListener('keydown', (event) => { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') { + event.preventDefault(); + send({ type: 'REQUEST_SAVE', requestId: 'nextcloud-exelearning-shortcut-' + Date.now() }); + } + }, true); + + const waitForReady = () => new Promise((resolve) => { + const tick = () => { + const ready = window.eXeLearning && window.eXeLearning.ready; + if (ready && typeof ready.then === 'function') ready.then(resolve); + else setTimeout(tick, 50); + }; + tick(); + }); + waitForReady().then(() => { + const bridge = window.eXeLearning && window.eXeLearning.app && window.eXeLearning.app.embeddingBridge; + if (!bridge) return; + bridge.handleSaveRequest = async function (requestId) { + const project = this.app.project; + const yjsBridge = project && project._yjsBridge; + const documentManager = yjsBridge && yjsBridge.documentManager; + if (!window.SharedExporters || !documentManager) { + throw new Error('Exporter unavailable'); + } + if (typeof documentManager._updateVersionMetadata === 'function') { + try { await documentManager._updateVersionMetadata(); } catch (_e) { void _e; } + } + const exporter = window.SharedExporters.createExporter( + 'elpx', documentManager, + yjsBridge.assetCache, yjsBridge.resourceFetcher, yjsBridge.assetManager + ); + const result = await exporter.export({}); + if (!result || !result.success || !result.data) { + throw new Error((result && result.error) || 'Export failed'); + } + const data = result.data; + const bytes = data instanceof ArrayBuffer + ? data + : (ArrayBuffer.isView(data) + ? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) + : data); + this.postToParent({ + type: 'SAVE_FILE', requestId, + bytes, filename: result.filename || 'project.elpx', + size: bytes.byteLength, + }); + }; + }); +})(); +JS; + } +} diff --git a/tests/Unit/Controller/EditorControllerTest.php b/tests/Unit/Controller/EditorControllerTest.php new file mode 100644 index 0000000..a88ea8b --- /dev/null +++ b/tests/Unit/Controller/EditorControllerTest.php @@ -0,0 +1,249 @@ +request = $this->createMock(IRequest::class); + $this->session = $this->createMock(IUserSession::class); + $this->packages = $this->createMock(ElpxPackageService::class); + $this->legacyMigration = $this->createMock(LegacyFileMigrationService::class); + $this->editorHtml = $this->createMock(EditorHtmlService::class); + $this->initialState = new EditorInitialStateRecorder(); + $this->urlGenerator = $this->createMock(IURLGenerator::class); + $this->urlGenerator->method('linkTo')->willReturn('/custom_apps/exelearning/'); + $this->urlGenerator->method('linkToRoute')->willReturn('/apps/exelearning/editor/iframe'); + $this->controller = new EditorController( + 'exelearning', + $this->request, + $this->session, + $this->packages, + $this->legacyMigration, + $this->editorHtml, + $this->initialState, + $this->urlGenerator, + ); + $this->editorIndexPath = dirname(__DIR__, 3) . '/js/editor/index.html'; + $this->editorIndexExisted = is_file($this->editorIndexPath); + if ($this->editorIndexExisted) { + $contents = file_get_contents($this->editorIndexPath); + $this->originalEditorIndex = is_string($contents) ? $contents : null; + } + } + + protected function tearDown(): void { + if ($this->editorIndexExisted) { + if ($this->originalEditorIndex !== null) { + file_put_contents($this->editorIndexPath, $this->originalEditorIndex); + } + return; + } + + @unlink($this->editorIndexPath); + @rmdir(dirname($this->editorIndexPath)); + } + + public function testIndexRequiresAuthentication(): void { + $this->session->method('getUser')->willReturn(null); + + $response = $this->controller->index(fileId: 42); + + self::assertSame(Http::STATUS_UNAUTHORIZED, $response->getStatus()); + } + + public function testIndexProvidesFileAndEditorStateById(): void { + $this->authenticate(); + $file = $this->packageFile(); + $this->packages->expects(self::once()) + ->method('getForUserById') + ->with('alice', 42) + ->willReturn($file); + + $response = $this->controller->index(fileId: 42); + + self::assertSame(42, $this->initialState->states['file']['id']); + self::assertSame('lesson.elpx', $this->initialState->states['file']['name']); + $this->assertEditorPageState($response); + } + + public function testIndexCanResolveByPathAndIgnoreLookupErrors(): void { + $this->authenticate(); + $file = $this->packageFile(); + $this->packages->expects(self::exactly(3)) + ->method('getForUserByPath') + ->willReturnOnConsecutiveCalls( + $file, + self::throwException(new NotFoundException('missing')), + self::throwException(new NotPermittedException('denied')), + ); + + self::assertSame(Http::STATUS_OK, $this->controller->index(path: 'lesson.elpx')->getStatus()); + $this->initialState->states = []; + Util::reset(); + self::assertSame(Http::STATUS_OK, $this->controller->index(path: 'missing.elpx')->getStatus()); + $this->initialState->states = []; + Util::reset(); + self::assertSame(Http::STATUS_OK, $this->controller->index(path: 'denied.elpx')->getStatus()); + } + + public function testIndexIgnoresIdLookupErrors(): void { + $this->authenticate(); + $this->packages->expects(self::exactly(2)) + ->method('getForUserById') + ->willReturnOnConsecutiveCalls( + self::throwException(new NotFoundException('missing')), + self::throwException(new NotPermittedException('denied')), + ); + + self::assertSame(Http::STATUS_OK, $this->controller->index(fileId: 42)->getStatus()); + self::assertSame(Http::STATUS_OK, $this->controller->index(fileId: 43)->getStatus()); + } + + public function testSaveRequiresAuthentication(): void { + $this->session->method('getUser')->willReturn(null); + + self::assertSame(Http::STATUS_UNAUTHORIZED, $this->controller->save(42)->getStatus()); + } + + public function testSaveMapsLookupErrors(): void { + $user = $this->authenticate(); + $this->packages->expects(self::exactly(2)) + ->method('getForUserById') + ->with($user->getUID(), 42) + ->willReturnOnConsecutiveCalls( + self::throwException(new NotFoundException('missing')), + self::throwException(new NotPermittedException('denied')), + ); + + self::assertSame(Http::STATUS_NOT_FOUND, $this->controller->save(42)->getStatus()); + self::assertSame(Http::STATUS_FORBIDDEN, $this->controller->save(42)->getStatus()); + } + + public function testSaveRejectsReadOnlyFile(): void { + $this->authenticate(); + $this->packages->method('getForUserById')->willReturn($this->packageFile(updateable: false)); + + self::assertSame(Http::STATUS_FORBIDDEN, $this->controller->save(42)->getStatus()); + } + + public function testSaveRejectsMissingUpload(): void { + $this->authenticate(); + $this->packages->method('getForUserById')->willReturn($this->packageFile()); + $this->request->method('getUploadedFile')->with('package')->willReturn(null); + + self::assertSame(Http::STATUS_BAD_REQUEST, $this->controller->save(42)->getStatus()); + } + + public function testIframeRequiresAuthentication(): void { + $this->session->method('getUser')->willReturn(null); + + self::assertSame(Http::STATUS_UNAUTHORIZED, $this->controller->iframe()->getStatus()); + } + + public function testIframeReportsMissingEditor(): void { + $this->authenticate(); + + self::assertSame(Http::STATUS_NOT_FOUND, $this->controller->iframe()->getStatus()); + } + + public function testIframeDelegatesHtmlPreparationAndSetsSecurityHeaders(): void { + $this->authenticate(); + $this->writeEditorIndex('