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 // `` at runtime) keeps it correct in both a normal install and // under a scoped path. - $staticConfig = json_encode([ - 'hideUI' => (object)[ - 'fileMenu' => true, - 'saveButton' => true, - 'shareButton' => false, - 'userMenu' => true, - 'downloadButton' => false, - 'helpMenu' => false, - ], - ], JSON_UNESCAPED_SLASHES); - - $configScript = ''; - - // The resilience shim must be installed before any editor script - // runs (it wraps fetch / jQuery.ajax / serviceWorker.register), so it - // goes right after and before the embedding config. - $resilienceScript = ''; - $headInject = '' . $resilienceScript . $configScript; - if (preg_match('/]*>/i', $html, $m, PREG_OFFSET_CAPTURE)) { - $pos = $m[0][1] + strlen($m[0][0]); - $html = substr($html, 0, $pos) . $headInject . substr($html, $pos); - } - $bridge = ''; - $html = str_ireplace('', $bridge . '', $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 = '' . $resilienceScript . $configScript; + if (preg_match('/]*>/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('Editor'); + $this->editorHtml->expects(self::once()) + ->method('prepare') + ->with( + 'Editor', + '/custom_apps/exelearning/js/editor/', + ) + ->willReturn('prepared'); + + $response = $this->controller->iframe(); + + self::assertSame(Http::STATUS_OK, $response->getStatus()); + self::assertSame('prepared', $response->getData()); + self::assertSame('text/html; charset=utf-8', $response->getHeaders()['Content-Type']); + self::assertStringContainsString("frame-ancestors 'self'", $response->getHeaders()['Content-Security-Policy']); + self::assertSame('nosniff', $response->getHeaders()['X-Content-Type-Options']); + self::assertSame('private, no-cache', $response->getHeaders()['Cache-Control']); + } + + private function authenticate(): IUser { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + $this->session->method('getUser')->willReturn($user); + return $user; + } + + private function packageFile(bool $updateable = true): File { + $file = $this->createMock(File::class); + $file->method('getId')->willReturn(42); + $file->method('getName')->willReturn('lesson.elpx'); + $file->method('getPath')->willReturn('/Lessons/lesson.elpx'); + $file->method('getMTime')->willReturn(123456); + $file->method('getEtag')->willReturn('etag-1'); + $file->method('isUpdateable')->willReturn($updateable); + return $file; + } + + private function assertEditorPageState(object $response): void { + self::assertSame(Http::STATUS_OK, $response->getStatus()); + self::assertSame(false, $this->initialState->states['editorAvailable']); + self::assertSame('/custom_apps/exelearning/js/editor', $this->initialState->states['editorBasePath']); + self::assertSame('/apps/exelearning/editor/iframe', $this->initialState->states['editorIframeUrl']); + self::assertSame([[Application::APP_ID, 'exelearning-editor']], Util::$scripts); + $policy = $response->getContentSecurityPolicy(); + self::assertNotNull($policy); + self::assertSame(["'self'"], $policy->scriptDomains); + self::assertSame(["'self'"], $policy->connectDomains); + self::assertSame(["'self'"], $policy->frameDomains); + } + + private function writeEditorIndex(string $html): void { + $dir = dirname($this->editorIndexPath); + if (!is_dir($dir)) { + mkdir($dir, 0777, true); + } + file_put_contents($this->editorIndexPath, $html); + } +} + +final class EditorInitialStateRecorder implements IInitialState { + /** @var array */ + public array $states = []; + + public function provideInitialState(string $key, mixed $value): void { + $this->states[$key] = $value; + } +} diff --git a/tests/Unit/Service/EditorHtmlServiceTest.php b/tests/Unit/Service/EditorHtmlServiceTest.php new file mode 100644 index 0000000..169c0db --- /dev/null +++ b/tests/Unit/Service/EditorHtmlServiceTest.php @@ -0,0 +1,48 @@ +service = new EditorHtmlService(); + } + + public function testInjectsBaseConfigResilienceAndSaveBridge(): void { + $html = 'Editor
Editor
'; + + $result = $this->service->prepare( + $html, + 'https://cloud.example/custom_apps/exelearning/js/editor/', + ); + + self::assertStringContainsString( + '', + $result, + ); + self::assertStringContainsString('window.__EXE_EMBEDDING_CONFIG__', $result); + self::assertStringContainsString('navigator.serviceWorker.register', $result); + self::assertStringContainsString("type: 'REQUEST_SAVE'", $result); + self::assertStringContainsString('SharedExporters.createExporter', $result); + self::assertStringContainsString('', $result); + } + + public function testEscapesBaseHrefAndStillAddsBridgeWithoutHead(): void { + $html = 'Editor'; + + $result = $this->service->prepare( + $html, + 'https://cloud.example/apps/exelearning/js/editor/?a=1&b="2"', + ); + + self::assertStringNotContainsString(' ../lib/AppInfo ../lib/Controller/AssetController.php + ../lib/Controller/EditorController.php ../lib/Controller/PackageController.php ../lib/Controller/SwController.php ../lib/Controller/TemplateController.php