From 3ff86ea790e63f039d13e7706177eb8a7a8787ea Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Mon, 23 Mar 2026 20:41:55 +0000 Subject: [PATCH] Allow install editor if not exists and many bugfixes --- .agents/skills/add-event/SKILL.md | 70 +++ .agents/skills/add-route/SKILL.md | 85 +++ .agents/skills/add-service/SKILL.md | 51 ++ .agents/skills/i18n/SKILL.md | 17 + .agents/skills/release/SKILL.md | 13 + .agents/skills/verify/SKILL.md | 16 + .github/copilot-instructions.md | 3 + .github/workflows/check-editor-releases.yml | 13 + .github/workflows/pr-playground-preview.yml | 92 ++++ .github/workflows/release.yml | 16 + .gitignore | 1 + AGENTS.md | 278 ++++++++++ Module.php | 196 ++++++- README.md | 2 +- asset/js/exelearning-editor.js | 16 +- asset/js/exelearning-installer.js | 405 +++++++++++++++ asset/js/omeka-exe-bridge.js | 23 +- blueprint.json | 2 +- claude.md | 228 +------- config/module.config.php | 23 + language/es.po | 104 ++++ src/Controller/ApiController.php | 308 ++++++++++- src/Controller/EditorController.php | 242 ++++++++- .../FileRenderer/ExeLearningRenderer.php | 80 ++- .../ExeLearningRendererFactory.php | 12 +- src/Service/ElpFileServiceFactory.php | 28 +- src/Service/StaticEditorInstaller.php | 486 ++++++++++++++++++ .../Controller/ApiControllerTest.php | 339 +++++++++++- .../FileRenderer/ExeLearningRendererTest.php | 112 +++- .../Service/StaticEditorInstallerTest.php | 366 +++++++++++++ test/Stubs/Laminas/Http/Request.php | 31 ++ .../Controller/AbstractActionController.php | 20 +- test/Stubs/Laminas/Uri/Http.php | 36 ++ view/exelearning/admin/media-show.phtml | 48 +- view/exelearning/editor-bootstrap.phtml | 56 +- view/exelearning/public/item-show.phtml | 27 +- 36 files changed, 3449 insertions(+), 396 deletions(-) create mode 100644 .agents/skills/add-event/SKILL.md create mode 100644 .agents/skills/add-route/SKILL.md create mode 100644 .agents/skills/add-service/SKILL.md create mode 100644 .agents/skills/i18n/SKILL.md create mode 100644 .agents/skills/release/SKILL.md create mode 100644 .agents/skills/verify/SKILL.md create mode 100644 .github/copilot-instructions.md create mode 100644 .github/workflows/pr-playground-preview.yml create mode 100644 AGENTS.md create mode 100644 asset/js/exelearning-installer.js create mode 100644 src/Service/StaticEditorInstaller.php create mode 100644 test/ExeLearningTest/Service/StaticEditorInstallerTest.php create mode 100644 test/Stubs/Laminas/Http/Request.php create mode 100644 test/Stubs/Laminas/Uri/Http.php diff --git a/.agents/skills/add-event/SKILL.md b/.agents/skills/add-event/SKILL.md new file mode 100644 index 0000000..03babb4 --- /dev/null +++ b/.agents/skills/add-event/SKILL.md @@ -0,0 +1,70 @@ +--- +name: add-event +description: Attach a new Omeka S event listener in Module.php. Invoke with a description, e.g. /add-event log media updates +--- + +Attach a new event listener for: $ARGUMENTS + +## Steps + +### 1. Register in `Module::attachListeners()` + +```php +public function attachListeners(SharedEventManagerInterface $sharedEventManager) +{ + // ... existing listeners ... + + $sharedEventManager->attach( + 'Omeka\Api\Adapter\MediaAdapter', // identifier (see table below) + 'api.update.post', // event name + [$this, 'handleMyEvent'] + ); +} +``` + +### 2. Add the handler method on `Module` + +```php +public function handleMyEvent(Event $event): void +{ + $services = $this->getServiceLocator(); + $logger = $services->get('Omeka\Logger'); + + /** @var \Omeka\Api\Request $request */ + $request = $event->getParam('request'); + /** @var \Omeka\Entity\Media $entity */ + $entity = $event->getParam('entity'); // available on api.*.post events + /** @var \Omeka\Api\Response $response */ + $response = $event->getParam('response'); // available on api.*.post events + + // your logic here +} +``` + +### Common event identifiers + +| Identifier | Fires for | +|---|---| +| `Omeka\Api\Adapter\MediaAdapter` | Media CRUD operations | +| `Omeka\Api\Adapter\ItemAdapter` | Item CRUD operations | +| `Omeka\Controller\Admin\Media` | Admin media pages | +| `Omeka\Controller\Admin\Item` | Admin item pages | +| `Omeka\Controller\Site\Item` | Public item pages | +| `*` | All controllers (use sparingly) | + +### Common event names + +| Event | When | Key params | +|---|---|---| +| `api.hydrate.post` | After entity hydration from request | `request`, `entity` | +| `api.create.post` | After entity created | `request`, `entity`, `response` | +| `api.update.post` | After entity updated | `request`, `entity`, `response` | +| `api.delete.pre` | Before entity deleted | `request`, `entity` | +| `view.show.after` | After show view rendered | view renderer as target | +| `view.layout` | Every page layout | view renderer as target | + +### Notes +- `$event->getTarget()` returns the view renderer on `view.*` events and the adapter on `api.*` events. +- Get the view in `view.*` handlers: `$view = $event->getTarget();` +- Always check the entity type before acting: `if (!$this->isExeLearningFile($media)) return;` +- Services available via `$this->getServiceLocator()` inside `Module` methods. diff --git a/.agents/skills/add-route/SKILL.md b/.agents/skills/add-route/SKILL.md new file mode 100644 index 0000000..dc0b6bd --- /dev/null +++ b/.agents/skills/add-route/SKILL.md @@ -0,0 +1,85 @@ +--- +name: add-route +description: Add a new route and controller action to this Omeka S module. Invoke with a short description, e.g. /add-route admin export endpoint +--- + +Add a new route and controller action for: $ARGUMENTS + +## Steps + +### 1. Add the route in `config/module.config.php` + +**Public/API route** (standalone): +```php +'router' => [ + 'routes' => [ + 'exelearning-myroute' => [ + 'type' => \Laminas\Router\Http\Segment::class, + 'options' => [ + 'route' => '/exelearning/my-route[/:id]', + 'constraints' => ['id' => '\d+'], + 'defaults' => [ + '__NAMESPACE__' => 'ExeLearning\Controller', + 'controller' => 'MyController', + 'action' => 'myAction', + ], + ], + ], + ], +], +``` + +**Admin child route** (under `/admin`): +```php +'admin' => [ + 'child_routes' => [ + 'exelearning-myroute' => [ + 'type' => \Laminas\Router\Http\Segment::class, + 'options' => [ + 'route' => '/exelearning/my-route[/:id]', + 'constraints' => ['id' => '\d+'], + 'defaults' => [ + '__NAMESPACE__' => 'ExeLearning\Controller', + 'controller' => 'MyController', + 'action' => 'myAction', + ], + ], + ], + ], +], +``` + +Use `Literal` for fixed paths, `Segment` for paths with `:param`, `Regex` for paths needing slashes inside a segment (like `exelearning-content`). + +### 2. Add the action to the controller + +In `src/Controller/MyController.php`: +```php +public function myActionAction() +{ + // Check ACL if needed: + $acl = $this->getServiceLocator()->get('Omeka\Acl'); + if (!$acl->userIsAllowed('Omeka\Entity\Media', 'read')) { + return $this->redirect()->toRoute('login'); + } + + $id = $this->params('id'); + // ... + return new \Laminas\View\Model\ViewModel(['data' => $data]); +} +``` + +For JSON responses: `return new \Laminas\View\Model\JsonModel(['key' => 'value']);` + +### 3. Add the view template (if not JSON) + +Create `view/exe-learning/my-controller/my-action.phtml`. + +### 4. Generate the URL in views/JS + +PHP: `$this->url('exelearning-myroute', ['id' => $id])` +JS: Use `$request->getBasePath()` prefix — the module supports playground prefix environments. + +### 5. Write a unit test + +Add a controller test in `test/ExeLearningTest/Controller/` following `ApiControllerTest.php` as the example. diff --git a/.agents/skills/add-service/SKILL.md b/.agents/skills/add-service/SKILL.md new file mode 100644 index 0000000..ad170b9 --- /dev/null +++ b/.agents/skills/add-service/SKILL.md @@ -0,0 +1,51 @@ +--- +name: add-service +description: Add a new Laminas service (with factory) to this Omeka S module. Invoke with the service name, e.g. /add-service MyNewService +--- + +Add a new service called $ARGUMENTS following the patterns in this module. + +## Steps + +1. **Create `src/Service/$ARGUMENTS.php`** — the service class in namespace `ExeLearning\Service`. + +2. **Create `src/Service/${ARGUMENTS}Factory.php`** — implements `Laminas\ServiceManager\Factory\FactoryInterface`: + ```php + namespace ExeLearning\Service; + use Interop\Container\ContainerInterface; + use Laminas\ServiceManager\Factory\FactoryInterface; + + class ${ARGUMENTS}Factory implements FactoryInterface + { + public function __invoke(ContainerInterface $services, $requestedName, array $options = null) + { + // pull dependencies from $services: + // $services->get('Omeka\ApiManager') + // $services->get('Omeka\EntityManager') + // $services->get('Omeka\Logger') + // $services->get('Config') + return new $ARGUMENTS(/* dependencies */); + } + } + ``` + +3. **Register in `config/module.config.php`** under `service_manager.factories`: + ```php + 'service_manager' => [ + 'factories' => [ + Service\$ARGUMENTS::class => Service\${ARGUMENTS}Factory::class, + ], + ], + ``` + +4. **Inject where needed** — in a controller factory retrieve it with: + ```php + $myService = $services->get(\ExeLearning\Service\$ARGUMENTS::class); + ``` + +5. **Write a unit test** in `test/ExeLearningTest/Service/` using PHPUnit. Add stubs to `test/Stubs/` for any new Omeka/Laminas collaborators not already stubbed. + +## Notes +- Factories are excluded from coverage requirements (see phpunit.xml). +- Common Omeka services: `Omeka\ApiManager`, `Omeka\EntityManager`, `Omeka\Logger`, `Omeka\Settings`, `Omeka\Acl`. +- Use `$services->get('Config')` for module config values. diff --git a/.agents/skills/i18n/SKILL.md b/.agents/skills/i18n/SKILL.md new file mode 100644 index 0000000..cff00dc --- /dev/null +++ b/.agents/skills/i18n/SKILL.md @@ -0,0 +1,17 @@ +--- +name: i18n +description: Run the full translation workflow — extract strings, update .po files, check for untranslated strings, and compile .mo files. +--- + +Run the full i18n pipeline in sequence. Stop and report any failure immediately. + +```bash +make i18n +``` + +This runs: `generate-pot` → `update-po` → `check-untranslated` → `compile-mo`. + +Report: +- Whether each step passed or failed +- Any untranslated strings found (file and string key) +- Confirmation that .mo files were compiled successfully diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md new file mode 100644 index 0000000..2ae0af4 --- /dev/null +++ b/.agents/skills/release/SKILL.md @@ -0,0 +1,13 @@ +--- +name: release +description: Package the module for distribution. Invoke with a version number, e.g. /release 1.2.3 +disable-model-invocation: true +--- + +Run the packaging command with the version provided in $ARGUMENTS: + +```bash +make package VERSION=$ARGUMENTS +``` + +Report the output path of the generated .zip file and confirm it completed successfully. diff --git a/.agents/skills/verify/SKILL.md b/.agents/skills/verify/SKILL.md new file mode 100644 index 0000000..eb7760a --- /dev/null +++ b/.agents/skills/verify/SKILL.md @@ -0,0 +1,16 @@ +--- +name: verify +description: Run the full CI verification pipeline locally — lint, unit tests, and 90% coverage check. Use after making changes to confirm they are ready. +--- + +Run the following commands in sequence. Stop and report any failure immediately. + +```bash +make lint +make test-coverage +``` + +Report: +- Whether lint passed or failed (and which files had violations) +- Whether tests passed and the coverage percentage achieved +- Any failures with the relevant error output diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..90c2d92 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,3 @@ +# Omeka S eXeLearning Plugin — Copilot Instructions + +All AI agent instructions live in [`AGENTS.md`](../AGENTS.md) at the repository root. Read that file before working on this codebase. diff --git a/.github/workflows/check-editor-releases.yml b/.github/workflows/check-editor-releases.yml index 00498b8..428844c 100644 --- a/.github/workflows/check-editor-releases.yml +++ b/.github/workflows/check-editor-releases.yml @@ -82,6 +82,15 @@ jobs: git commit -m "Update editor version to ${{ steps.check.outputs.tag }}" git push + - name: Build Playground URL for this release + if: steps.check.outputs.found == 'true' + id: playground + run: | + RELEASE_URL="https://github.com/${{ github.repository }}/releases/download/${{ steps.version.outputs.tag }}/ExeLearning-${{ steps.version.outputs.version }}.zip" + BLUEPRINT=$(jq --arg url "$RELEASE_URL" '.modules[0].source.url = $url' blueprint.json) + ENCODED=$(echo "$BLUEPRINT" | base64 -w 0 | tr '+/' '-_' | tr -d '=') + echo "url=https://ateeducacion.github.io/omeka-s-playground/?blueprint-data=${ENCODED}" >> $GITHUB_OUTPUT + - name: Create GitHub Release if: steps.check.outputs.found == 'true' uses: softprops/action-gh-release@v2 @@ -90,6 +99,10 @@ jobs: name: "${{ steps.version.outputs.tag }}" body: | Automated build with eXeLearning editor ${{ steps.version.outputs.tag }}. + + --- + + Try in Omeka-S Playground files: ExeLearning-${{ steps.version.outputs.version }}.zip env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pr-playground-preview.yml b/.github/workflows/pr-playground-preview.yml new file mode 100644 index 0000000..4007142 --- /dev/null +++ b/.github/workflows/pr-playground-preview.yml @@ -0,0 +1,92 @@ +--- +name: PR Playground Preview + +on: + pull_request: + types: [opened, synchronize, reopened, edited] + +permissions: + contents: read + pull-requests: write + +jobs: + preview: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Post or update playground preview comment + uses: actions/github-script@v8 + with: + script: | + const fs = require('fs'); + const marker = ''; + const owner = context.repo.owner; + const repo = context.repo.repo; + const headRef = context.payload.pull_request.head.ref; + const prNumber = context.payload.pull_request.number; + + // Read the root blueprint and replace the module URL with the PR branch ZIP + const blueprint = JSON.parse(fs.readFileSync('blueprint.json', 'utf8')); + const prZipUrl = `https://github.com/${owner}/${repo}/archive/refs/heads/${headRef}.zip`; + if (blueprint.modules) { + for (const mod of blueprint.modules) { + if (mod.source && mod.source.url && mod.source.url.includes(`${owner}/${repo}`)) { + mod.source.url = prZipUrl; + } + } + } + + // Encode as base64url + function toBase64url(str) { + return Buffer.from(str) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + } + + const encoded = toBase64url(JSON.stringify(blueprint)); + const playgroundUrl = `https://ateeducacion.github.io/omeka-s-playground/?blueprint-data=${encoded}`; + const imageUrl = 'https://raw.githubusercontent.com/ateeducacion/omeka-s-playground/refs/heads/main/ogimage.png'; + + const body = [ + marker, + '## omeka-s Playground Preview', + '', + ``, + ` Open this PR in Omeka-S Playground`, + '
', + `Try this PR in your browser`, + '', + '', + '> ⚠️ The embedded eXeLearning editor is not included in this preview. You can install it from **Modules > ExeLearning > Configure** using the "Download & Install Editor" button. All other module features (ELPX upload, viewer, preview) work normally.', + ].join('\n'); + + // Search for an existing comment with the marker + const { data: comments } = await github.rest.issues.listComments({ + owner, + repo, + issue_number: prNumber, + }); + + const existing = comments.find(c => c.body && c.body.includes(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + core.info(`Updated existing preview comment #${existing.id}`); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body, + }); + core.info('Created new preview comment'); + } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1a9b18d..3117dd4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -76,11 +76,27 @@ jobs: name: ExeLearning-${{ env.RELEASE_TAG }} path: ExeLearning-${{ env.RELEASE_TAG }}.zip + - name: Build Playground URL for this release + if: github.event_name == 'push' + id: playground + run: | + TAG_NAME="${GITHUB_REF##*/}" + RELEASE_URL="https://github.com/${{ github.repository }}/releases/download/${TAG_NAME}/ExeLearning-${RELEASE_TAG}.zip" + BLUEPRINT=$(jq --arg url "$RELEASE_URL" '.modules[0].source.url = $url' blueprint.json) + ENCODED=$(echo "$BLUEPRINT" | base64 -w 0 | tr '+/' '-_' | tr -d '=') + echo "url=https://ateeducacion.github.io/omeka-s-playground/?blueprint-data=${ENCODED}" >> $GITHUB_OUTPUT + - name: Publish/Update GitHub Release uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.event_name == 'push' && github.ref_name || env.RELEASE_TAG }} files: ExeLearning-${{ env.RELEASE_TAG }}.zip generate_release_notes: true + append_body: true + body: | + + --- + + Try in Omeka-S Playground env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index a6c170a..299762e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ asset/static # Local editor checkout fetched during build exelearning/ .env +.playwright-mcp/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1908022 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,278 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Workflow + +**Test and lint (required before marking done):** +```bash +make lint # PHP_CodeSniffer PSR2 — must pass +make test # PHPUnit unit tests +make test-coverage # Tests + 90% coverage minimum (used in CI) +``` + +**Docker dev environment:** +```bash +make up # Start → http://localhost:8080 (admin@example.com / PLEASE_CHANGEME) +make shell # SSH into container +make down # Stop +``` + +**Editor build** (requires [Bun](https://bun.sh/), not npm/yarn): +```bash +make build-editor # Build static editor from exelearning submodule +``` + +**Packaging:** +```bash +make package VERSION=1.2.3 +``` + +## Code Style + +- PSR2 standard enforced by phpcs. Run `make fix` to auto-fix. +- Factories are excluded from coverage requirements. +- Test stubs live in `test/Stubs/` — add new stubs there when Omeka/Laminas collaborators are needed. + +## Git Conventions + +- Branch names: `feature/*`, `fix/*`, `hotfix/*` +- CI runs on push/PR: untranslated string check, lint, test-coverage (all must pass) + +## Architecture Notes + +See the Security & Architecture section below for the full system design. Key gotchas: +- All ELPX content is served through `ContentController` (proxy) — never expose `/files/exelearning/` directly. +- API endpoints validate CSRF tokens from session — always include `csrf_key` in API calls. +- The module uses Omeka event hooks (`api.hydrate.post`, `api.create.post`, `api.delete.pre`, `view.show.after`) — check `Module.php` before adding new lifecycle behavior. +- URL building uses `$request->getBasePath()` to support playground prefix environments. + +--- + +# ExeLearning Module for Omeka S - Security & Architecture + +This document describes the security considerations and system architecture implemented in the ExeLearning module. + +## Architecture Overview + +The module enables viewing and editing of eXeLearning (.elpx) files within Omeka S. The system consists of: + +``` ++------------------+ +-------------------+ +------------------+ +| Admin Interface | | Content Proxy | | Editor (iframe) | +| (media-show) |---->| (ContentController)|-->| (eXeLearning) | ++------------------+ +-------------------+ +------------------+ + | | | + v v v ++------------------+ +-------------------+ +------------------+ +| Modal Editor | | /files/exelearning/| | postMessage API | +| (fullscreen) | | (extracted files) | | (communication) | ++------------------+ +-------------------+ +------------------+ + | | + v v ++------------------+ +------------------+ +| API Controller |<-----------------------------| Bridge JS | +| (save/load) | | (import/export) | ++------------------+ +------------------+ +``` + +## File Storage + +- **Original .elpx files**: Stored in Omeka's standard `/files/original/` directory +- **Extracted content**: Stored in `/files/exelearning/{sha1-hash}/` directories +- **Thumbnails**: Generated and stored as custom thumbnails for media items + +## Security Measures + +### 1. Iframe Sandboxing + +All iframes displaying eXeLearning content use restrictive sandbox attributes: + +```html + +``` + +**Allowed capabilities:** +- `allow-scripts`: Required for interactive content +- `allow-popups`: Some eXeLearning content may need popups +- `allow-popups-to-escape-sandbox`: Popups can function normally + +**Blocked capabilities:** +- `allow-same-origin`: Prevents access to parent page cookies/storage +- `allow-forms`: Prevents form submission to external URLs +- `allow-top-navigation`: Prevents navigation of parent page + +### 2. Content Security Policy (CSP) + +The ContentController adds strict CSP headers for HTML content: + +``` +Content-Security-Policy: + default-src 'self'; + script-src 'self' 'unsafe-inline' 'unsafe-eval'; + style-src 'self' 'unsafe-inline'; + img-src 'self' data: blob:; + media-src 'self' data: blob:; + font-src 'self' data:; + frame-src 'self'; + object-src 'none'; + base-uri 'self' +``` + +This prevents: +- Loading external scripts (XSS mitigation) +- Connecting to external servers +- Embedding external iframes +- Using plugins (Flash, Java, etc.) + +### 3. Secure Content Proxy + +Direct access to `/files/exelearning/` is blocked. All content is served through a PHP proxy (`ContentController::serveAction`): + +**Security validations:** +1. Hash format validation (40 hex characters - SHA1) +2. Path traversal prevention (blocks `..`) +3. File existence verification +4. MIME type detection and Content-Type headers + +```php +// Hash validation +if (!preg_match('/^[a-f0-9]{40}$/', $hash)) { + return $this->notFoundAction(); +} + +// Path traversal prevention +if (strpos($file, '..') !== false) { + return $this->notFoundAction(); +} +``` + +### 4. Additional Security Headers + +``` +X-Frame-Options: SAMEORIGIN +X-Content-Type-Options: nosniff +``` + +- **X-Frame-Options**: Prevents clickjacking by blocking external framing +- **X-Content-Type-Options**: Prevents MIME-sniffing attacks + +### 5. Server Configuration (nginx) + +The module requires nginx rules to: + +1. **Block direct file access:** +```nginx +location ^~ /files/exelearning/ { + return 403; +} +``` + +2. **Route proxy requests to PHP:** +```nginx +location ^~ /exelearning/content/ { + try_files $uri /index.php$is_args$args; +} +``` + +### 6. CSRF Protection + +API endpoints require a valid CSRF key: + +```php +$csrfKey = $data['csrf_key'] ?? $request->getQuery('csrf_key'); +$session = Container::getDefaultManager()->getStorage(); +if (!$session || $csrfKey !== ($session['Omeka\Csrf'] ?? null)) { + return new ApiProblemResponse(new ApiProblem(403, 'Invalid CSRF token')); +} +``` + +### 7. ACL Permissions + +Edit functionality requires proper permissions: + +```php +$acl = $this->getServiceLocator()->get('Omeka\Acl'); +if (!$acl->userIsAllowed('Omeka\Entity\Media', 'update')) { + return new ApiProblemResponse(new ApiProblem(403, 'Permission denied')); +} +``` + +## Communication Flow + +### Parent-Iframe Communication + +Communication uses `postMessage` API with origin validation: + +**Editor to Parent:** +```javascript +window.parent.postMessage({ + type: 'exelearning-bridge-ready' +}, window.location.origin); + +window.parent.postMessage({ + type: 'exelearning-save-complete', + success: true +}, window.location.origin); +``` + +**Parent to Editor:** +```javascript +iframe.contentWindow.postMessage({ + type: 'exelearning-request-save' +}, '*'); +``` + +### Save Flow + +1. User clicks "Save to Omeka" button +2. Parent sends `exelearning-request-save` message +3. Bridge exports ELPX from editor +4. Bridge POSTs to `/api/exelearning/save/{id}` with CSRF token +5. Server validates token, permissions, and saves file +6. Bridge sends `exelearning-save-complete` message +7. Parent closes modal and refreshes preview + +## File Types and MIME Detection + +The module handles various file types within .elpx archives: + +| Extension | MIME Type | +|-----------|-----------| +| .html | text/html | +| .css | text/css | +| .js | application/javascript | +| .json | application/json | +| .png | image/png | +| .jpg/.jpeg | image/jpeg | +| .gif | image/gif | +| .svg | image/svg+xml | +| .mp4 | video/mp4 | +| .webm | video/webm | +| .mp3 | audio/mpeg | +| .ogg | audio/ogg | +| .woff/.woff2 | font/woff, font/woff2 | +| .ttf | font/ttf | +| .pdf | application/pdf | + +## Potential Attack Vectors (Mitigated) + +1. **XSS via uploaded content**: Mitigated by CSP headers and iframe sandboxing +2. **Path traversal**: Mitigated by `..` filtering and hash validation +3. **CSRF attacks**: Mitigated by CSRF token validation +4. **Unauthorized editing**: Mitigated by ACL permission checks +5. **Clickjacking**: Mitigated by X-Frame-Options header +6. **Direct file access**: Mitigated by nginx rules blocking /files/exelearning/ +7. **MIME sniffing**: Mitigated by X-Content-Type-Options header + +## Recommendations for Administrators + +1. Ensure nginx is properly configured with the blocking rules +2. Review CSP headers if specific eXeLearning content requires external resources +3. Keep the module updated for security patches +4. Monitor server logs for suspicious access patterns +5. Consider additional rate limiting on API endpoints diff --git a/Module.php b/Module.php index f8f9897..0e3da6b 100644 --- a/Module.php +++ b/Module.php @@ -12,6 +12,7 @@ use Omeka\Mvc\Controller\Plugin\Messenger; use Omeka\Stdlib\Message; use ExeLearning\Form\ConfigForm; +use ExeLearning\Service\StaticEditorInstaller; /** * Main class for the ExeLearning module. @@ -195,7 +196,7 @@ public function handlePublicItemShow(Event $event) $result = $elpService->processUploadedFile($media); $hash = $result['hash']; $hasPreview = $result['hasPreview']; - } catch (\Exception $e) { + } catch (\Throwable $e) { $logger->err(sprintf('[ExeLearning] Auto-process failed: %s', $e->getMessage())); continue; } @@ -205,15 +206,16 @@ public function handlePublicItemShow(Event $event) continue; } - // Use secure content proxy instead of direct file access - $previewUrl = $view->url('exelearning-content', ['hash' => $hash, 'file' => 'index.html']); + // Pass the relative content path; JS constructs the full URL from + // window.location so the playground SW scope prefix is always included. + $contentPath = '/exelearning/content/' . $hash . '/index.html'; if (!$this->isTeacherModeVisible($media)) { - $previewUrl .= '?teacher_mode_visible=0'; + $contentPath .= '?teacher_mode_visible=0'; } echo $view->partial('exelearning/public/item-show', [ 'media' => $media, - 'previewUrl' => $previewUrl, + 'contentPath' => $contentPath, ]); } } @@ -247,7 +249,7 @@ public function handleAdminMediaShow(Event $event) $hash = $result['hash']; $hasPreview = $result['hasPreview']; $logger->info(sprintf('[ExeLearning] Auto-process complete: hash=%s, hasPreview=%s', $hash, $hasPreview ? 'yes' : 'no')); - } catch (\Exception $e) { + } catch (\Throwable $e) { $logger->err(sprintf('[ExeLearning] Auto-process failed: %s', $e->getMessage())); return; } @@ -257,15 +259,16 @@ public function handleAdminMediaShow(Event $event) return; } - // Use secure content proxy instead of direct file access - $previewUrl = $view->url('exelearning-content', ['hash' => $hash, 'file' => 'index.html']); + // Pass the relative content path; JS constructs the full URL from + // window.location so the playground SW scope prefix is always included. + $contentPath = '/exelearning/content/' . $hash . '/index.html'; if (!$this->isTeacherModeVisible($media)) { - $previewUrl .= '?teacher_mode_visible=0'; + $contentPath .= '?teacher_mode_visible=0'; } echo $view->partial('exelearning/admin/media-show', [ 'media' => $media, - 'previewUrl' => $previewUrl, + 'contentPath' => $contentPath, ]); } @@ -419,7 +422,7 @@ protected function getExeLearningItemIds(): array $results = $stmt->fetchAll(\PDO::FETCH_COLUMN); return array_map('intval', $results); - } catch (\Exception $e) { + } catch (\Throwable $e) { $logger = $services->get('Omeka\Logger'); $logger->err(sprintf('[ExeLearning] Failed to get item IDs: %s', $e->getMessage())); return []; @@ -498,7 +501,7 @@ public function handleMediaCreate(Event $event) try { $media = $services->get('Omeka\ApiManager') ->read('media', $mediaId)->getContent(); - } catch (\Exception $e) { + } catch (\Throwable $e) { $logger->err(sprintf( 'ExeLearning: Could not load media representation for %d: %s', $mediaId, @@ -525,7 +528,7 @@ public function handleMediaCreate(Event $event) $result['hash'], $result['hasPreview'] ? 'yes' : 'no' )); - } catch (\Exception $e) { + } catch (\Throwable $e) { $logger->err(sprintf( 'ExeLearning: Failed to process uploaded file for media %d: %s', $mediaId, @@ -577,7 +580,7 @@ public function handleMediaDelete(Event $event) $this->deleteDirectory($extractPath); $logger->info(sprintf('ExeLearning: Deleted extracted content at %s', $extractPath)); } - } catch (\Exception $e) { + } catch (\Throwable $e) { $logger->err(sprintf( 'ExeLearning: Failed to cleanup media: %s', $e->getMessage() @@ -616,6 +619,47 @@ protected function deleteDirectory(string $dir): void * @param mixed $media * @return bool */ + /** + * Build an absolute content proxy URL for the given hash. + * + * Derives the base path from the actual request URI path so that the + * playground prefix (/playground/{uuid}/php83/) is correctly included + * even in PHP-WASM environments where $_SERVER['SCRIPT_NAME'] does not + * contain it (making getBasePath() unreliable). + */ + protected function buildContentUrl(string $hash): string + { + $request = $this->getServiceLocator()->get('Request'); + $uri = $request->getUri(); + $scheme = $uri->getScheme(); + $port = $uri->getPort(); + $serverUrl = $scheme . '://' . $uri->getHost(); + if ($port && !(($scheme === 'http' && $port == 80) || ($scheme === 'https' && $port == 443))) { + $serverUrl .= ':' . $port; + } + $basePath = $this->extractBasePath($uri->getPath()); + return $serverUrl . $basePath . '/exelearning/content/' . $hash . '/index.html'; + } + + /** + * Derive the Omeka base path from the actual request URI path. + * + * Strips everything from the first known Omeka route segment onward + * (/admin/, /s/, /api/). This is reliable in PHP-WASM playgrounds where + * the full URL path (e.g. /playground/{uuid}/php83/admin/...) is preserved + * in the request URI even when $_SERVER['SCRIPT_NAME'] is not. + */ + protected function extractBasePath(string $uriPath): string + { + foreach (['/admin/', '/s/', '/api/'] as $marker) { + $pos = strpos($uriPath, $marker); + if ($pos !== false) { + return substr($uriPath, 0, $pos); + } + } + return ''; + } + /** * Check if teacher mode toggler should be visible for a media resource. */ @@ -660,7 +704,129 @@ public function getConfigForm(PhpRenderer $renderer) 'exelearning_show_edit_button' => $settings->get('exelearning_show_edit_button', true) ? '1' : '0', ]); - return $renderer->formCollection($form, false); + $formHtml = $renderer->formCollection($form, false); + + return $this->renderEditorStatusSection($renderer, $settings) . $formHtml; + } + + /** + * Render the embedded editor status and install section. + * + * @param PhpRenderer $renderer + * @param mixed $settings Omeka settings service + * @return string + */ + protected function renderEditorStatusSection(PhpRenderer $renderer, $settings): string + { + $isInstalled = StaticEditorInstaller::isEditorInstalled(); + $version = $settings->get(StaticEditorInstaller::SETTING_VERSION, ''); + $installedAt = $settings->get(StaticEditorInstaller::SETTING_INSTALLED_AT, ''); + + $translate = function ($text) use ($renderer) { + return $renderer->translate($text); + }; + + // Get CSRF token from the page form element. + $csrf = new \Laminas\Form\Element\Csrf('csrf'); + $csrfValue = $csrf->getValue(); + + $html = '
'; + $html .= '' . $renderer->escapeHtml($translate('Embedded Editor')) . ''; // @translate + + // Status display + $html .= '
'; + $html .= ''; // @translate + $html .= '
'; + $html .= ''; + if ($isInstalled) { + $html .= ' '; + $html .= $renderer->escapeHtml($translate('Installed')); // @translate + if ($version) { + $html .= ' — v' . $renderer->escapeHtml($version); + } + if ($installedAt) { + $html .= ' (' . $renderer->escapeHtml($translate('installed on')); // @translate + $html .= ' ' . $renderer->escapeHtml($installedAt) . ')'; + } + } else { + $html .= ' '; + $html .= $renderer->escapeHtml($translate('Not installed')); // @translate + } + $html .= ''; + $html .= '
'; + + // Install/update button + status area + $html .= '
'; + if (!$isInstalled) { + $html .= '

'; + $html .= $renderer->escapeHtml($translate( // @translate + 'The embedded eXeLearning editor is not installed.' + . ' You can download and install the latest version automatically from GitHub.' + )); + $html .= '

'; + } + + $buttonLabel = $isInstalled + ? $renderer->escapeHtml($translate('Update to Latest Version')) // @translate + : $renderer->escapeHtml($translate('Download & Install Editor')); // @translate + $buttonClass = $isInstalled ? 'button' : 'button active'; + $html .= ''; + + // Progress area (hidden by default) + $html .= ''; + + // Result area (hidden by default) + $html .= ''; + + $html .= '
'; + + $html .= '
'; + $html .= '

'; + $html .= sprintf( + $renderer->escapeHtml($translate('Developers can also build the editor from source using %s.')), // @translate + 'make build-editor' + ); + $html .= '

'; + $html .= '
'; + + $html .= '
'; + + // Configuration for the external installer JS + $installUrl = $renderer->serverUrl() . $renderer->basePath() . '/admin/exelearning/install-editor'; + $jsConfig = [ + 'installUrl' => $installUrl, + 'csrfToken' => $csrfValue, + 'githubApiUrl' => StaticEditorInstaller::GITHUB_API_URL, + 'jsdelivrApiUrl' => StaticEditorInstaller::JSDELIVR_API_URL, + 'assetPrefix' => StaticEditorInstaller::ASSET_PREFIX, + 'strings' => [ + 'pleaseWait' => $translate('Please wait...'), // @translate + 'discovering' => $translate('Checking latest version...'), // @translate + 'installing' => $translate('Installing editor...'), // @translate + 'downloading' => $translate('Downloading editor...'), // @translate + 'downloadingProgress' => $translate('Downloading... {downloaded} MB / {total} MB'), // @translate + 'error' => $translate('Installation failed.'), // @translate + 'networkError' => $translate('Network error. Please check your connection and try again.'), // @translate + 'downloadFailed' => $translate('Could not download the editor. All download sources failed.'), // @translate + 'tryAgain' => $translate('Try Again'), // @translate + ], + ]; + + $html .= ''; + $html .= ''; + + return $html; } /** diff --git a/README.md b/README.md index c51ec68..128f708 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ By default, `make build-editor` fetches `https://github.com/exelearning/exelearn EXELEARNING_EDITOR_REF=vX.Y.Z EXELEARNING_EDITOR_REF_TYPE=tag make build-editor ``` -> **Important:** Cloning the repository without building the editor will show version `0.0.0` and the editor will not work. Always download from [Releases](https://github.com/exelearning/omeka-s-exelearning/releases) for production use. +> **Important:** It is recommended to download from [Releases](https://github.com/exelearning/omeka-s-exelearning/releases) for production use, which includes the embedded editor pre-built. If you clone the repository without building the editor, you can install it from the Omeka S admin panel at **Modules > ExeLearning > Configure** using the "Download & Install Editor" button, which fetches the latest static editor package from GitHub Releases automatically. No remote loading is used at runtime. ## Usage diff --git a/asset/js/exelearning-editor.js b/asset/js/exelearning-editor.js index e7017ae..48377bd 100644 --- a/asset/js/exelearning-editor.js +++ b/asset/js/exelearning-editor.js @@ -330,11 +330,17 @@ * @param {object} data The message data. */ onSaveComplete: function(data) { - // Destroy the iframe to prevent beforeunload dialog - this.destroyIframe(); - - // Reload the page to show updated content - window.location.reload(); + // Update the admin preview iframe with the new content URL. + // Use window.exelearningContentBase (set by the page's inline script) + // to prepend the correct base including the playground SW scope prefix. + var previewIframe = document.querySelector('.preview-iframe'); + if (data.contentPath && previewIframe) { + var base = window.exelearningContentBase || window.location.origin; + previewIframe.src = base + data.contentPath; + } + // Always close the modal after a successful save — avoid + // window.location.reload() which 404s in PHP-WASM playground. + this.close(); } }; diff --git a/asset/js/exelearning-installer.js b/asset/js/exelearning-installer.js new file mode 100644 index 0000000..3a65d37 --- /dev/null +++ b/asset/js/exelearning-installer.js @@ -0,0 +1,405 @@ +/** + * eXeLearning editor installer with adaptive strategy. + * + * Detects Playground (PHP-WASM) and picks the fastest path: + * + * Playground → tell PHP to download via proxy URL + * → fallback: browser downloads via proxy + uploads blob + * Normal → server-side PHP download from GitHub + * → fallback: browser downloads via proxy + uploads blob + * + * Expects a global `exelearningInstaller` object. + */ +(function () { + 'use strict'; + + var cfg = window.exelearningInstaller; + if (!cfg) return; + + var btn = document.getElementById('exelearning-install-btn'); + var progressDiv = document.getElementById('exelearning-install-progress'); + var messageSpan = document.getElementById('exelearning-install-message'); + var progressBar = document.getElementById('exelearning-install-bar'); + var resultDiv = document.getElementById('exelearning-install-result'); + var s = cfg.strings; + + if (!btn) return; + + var KNOWN_PROXY = 'https://zip-proxy.erseco.workers.dev/'; + + /** + * Resolve the install API URL. + * In the Playground, the SW scopes URLs under /playground/SCOPE/RUNTIME/. + * PHP's basePath() doesn't include this prefix, so we derive it from + * the current page URL to ensure the SW routes the request correctly. + */ + function getInstallUrl() { + if (!isPlayground()) return cfg.installUrl; + var path = window.location.pathname; + var adminIdx = path.indexOf('/admin'); + if (adminIdx === -1) return cfg.installUrl; + return path.substring(0, adminIdx) + '/admin/exelearning/install-editor'; + } + + // ── Helpers ────────────────────────────────────────────────────────── + + function escapeHtml(str) { + var div = document.createElement('div'); + div.appendChild(document.createTextNode(str)); + return div.innerHTML; + } + + function showError(message) { + progressDiv.style.display = 'none'; + resultDiv.style.display = 'block'; + resultDiv.innerHTML = '

' + escapeHtml(message) + '

'; + btn.disabled = false; + btn.textContent = s.tryAgain; + } + + function showSuccess(message) { + setProgress(100, ''); + resultDiv.style.display = 'block'; + resultDiv.innerHTML = '

' + + escapeHtml(message) + '

'; + setTimeout(function () { location.reload(); }, 2000); + } + + function setProgress(pct, message) { + progressBar.style.width = Math.min(pct, 100) + '%'; + if (message) messageSpan.textContent = message; + } + + function proxiedUrl(url, proxyBase) { + if (!proxyBase) return null; + try { + var p = new URL(proxyBase); + p.searchParams.set('url', url); + return p.toString(); + } catch (e) { return null; } + } + + /** Parse response as JSON safely; extract message from HTML errors. */ + function safeJsonResponse(resp) { + var ct = resp.headers.get('Content-Type') || ''; + if (ct.indexOf('json') !== -1) { + return resp.json(); + } + return resp.text().then(function (text) { + var msg = text; + // Try
, 

, then content + var match = text.match(/]*>([\s\S]*?)<\/pre>/i) + || text.match(/]*>([\s\S]*?)<\/body>/i); + if (match) msg = match[1].replace(/<[^>]+>/g, '').trim(); + if (msg.length > 300) msg = msg.substring(0, 300) + '...'; + return { + success: false, + message: msg || ('Server error: HTTP ' + resp.status) + }; + }); + } + + function handleResult(data) { + if (data && data.success) { + showSuccess(data.message); + return true; + } + return false; + } + + // ── Environment detection ─────────────────────────────────────────── + + function isPlayground() { + if (window.location.pathname.indexOf('/omeka-s-playground') !== -1) return true; + if (window.location.search.indexOf('scope=') !== -1) return true; + var host = window.location.hostname; + if (host.indexOf('github.io') !== -1 && window.location.pathname.indexOf('playground') !== -1) { + return true; + } + if (typeof window.__playgroundConfig !== 'undefined') return true; + if (/\/playground\/[^/]+\/[^/]+\//.test(window.location.pathname)) return true; + return false; + } + + function getProxyUrl() { + try { + if (window.__playgroundConfig) { + return window.__playgroundConfig.addonProxyUrl + || window.__playgroundConfig.proxyUrl + || KNOWN_PROXY; + } + } catch (e) { /* ignore */ } + return KNOWN_PROXY; + } + + // ── Version discovery ─────────────────────────────────────────────── + + function discoverVersion() { + return fetch(cfg.githubApiUrl, { + headers: { 'Accept': 'application/vnd.github.v3+json' } + }) + .then(function (resp) { + if (!resp.ok) throw new Error('GitHub API error'); + return resp.json(); + }) + .then(function (data) { + var tag = data.tag_name || ''; + if (!tag) throw new Error('No tag_name'); + return tag.replace(/^v/, ''); + }) + .catch(function () { + return fetch(cfg.jsdelivrApiUrl) + .then(function (resp) { + if (!resp.ok) throw new Error('jsDelivr error'); + return resp.json(); + }) + .then(function (data) { + var ver = data.version || ''; + if (!ver) throw new Error('No version'); + return ver.replace(/^v/, ''); + }); + }); + } + + // ── Download with progress ────────────────────────────────────────── + + function buildReleaseUrl(version) { + return 'https://github.com/exelearning/exelearning/releases/download/v' + + version + '/' + cfg.assetPrefix + version + '.zip'; + } + + function buildProxyDownloadUrl(version) { + return proxiedUrl(buildReleaseUrl(version), getProxyUrl()); + } + + function downloadWithProgress(url) { + return fetch(url).then(function (response) { + if (!response.ok) { + throw new Error('Download failed: HTTP ' + response.status); + } + + var contentLength = response.headers.get('Content-Length'); + var total = contentLength ? parseInt(contentLength, 10) : 0; + + if (!total || !response.body || !response.body.getReader) { + setProgress(30, s.downloading); + return response.blob(); + } + + var reader = response.body.getReader(); + var received = 0; + var chunks = []; + + function pump() { + return reader.read().then(function (result) { + if (result.done) return; + chunks.push(result.value); + received += result.value.length; + var pct = 5 + Math.round((received / total) * 75); + var mb = (received / (1024 * 1024)).toFixed(1); + var totalMb = (total / (1024 * 1024)).toFixed(1); + setProgress(pct, s.downloadingProgress + .replace('{downloaded}', mb) + .replace('{total}', totalMb)); + return pump(); + }); + } + + return pump().then(function () { + return new Blob(chunks); + }); + }); + } + + // ── Install methods ───────────────────────────────────────────────── + + /** Tell PHP to download from a URL (no browser download needed). */ + function installViaUrl(downloadUrl, version) { + var formData = new FormData(); + formData.append('csrf', cfg.csrfToken); + formData.append('download_url', downloadUrl); + formData.append('version', version); + + return fetch(getInstallUrl(), { + method: 'POST', + body: formData, + credentials: 'same-origin' + }).then(safeJsonResponse); + } + + /** + * Upload blob to PHP via chunked upload. + * Splits the ZIP into small chunks (256KB) to avoid BroadcastChannel + * message size limits in PHP-WASM. Each chunk is a separate request. + */ + function uploadAndInstall(blob, version) { + setProgress(82, s.installing); + + var CHUNK_SIZE = 256 * 1024; // 256KB per chunk + var totalChunks = Math.ceil(blob.size / CHUNK_SIZE); + var uploadId = randomId(); + var baseUrl = getInstallUrl(); + var csrfParam = '&csrf=' + encodeURIComponent(cfg.csrfToken); + + function sendChunk(index) { + var start = index * CHUNK_SIZE; + var end = Math.min(start + CHUNK_SIZE, blob.size); + var chunk = blob.slice(start, end); + + var url = baseUrl + '?action=chunk&upload_id=' + uploadId + csrfParam; + + return fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/octet-stream' }, + body: chunk, + credentials: 'same-origin' + }) + .then(safeJsonResponse) + .then(function (data) { + if (!data.success) throw new Error(data.message || s.error); + + var pct = 82 + Math.round(((index + 1) / totalChunks) * 13); + setProgress(pct, s.installing + ' (' + (index + 1) + '/' + totalChunks + ')'); + + if (index + 1 < totalChunks) { + return sendChunk(index + 1); + } + }); + } + + // Send all chunks sequentially, then finalize + return sendChunk(0).then(function () { + setProgress(96, s.installing); + + var url = baseUrl + '?action=finalize&upload_id=' + uploadId + + '&version=' + encodeURIComponent(version) + csrfParam; + + return fetch(url, { + method: 'POST', + credentials: 'same-origin' + }).then(safeJsonResponse); + }); + } + + function randomId() { + var chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + var id = ''; + for (var i = 0; i < 16; i++) { + id += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return id; + } + + /** Ask PHP to download from GitHub directly (no proxy needed). */ + function serverSideInstall() { + var progress = 0; + var timer = setInterval(function () { + if (progress < 90) { + progress += (90 - progress) * 0.05; + setProgress(progress, s.downloading); + } + }, 500); + + var formData = new FormData(); + formData.append('csrf', cfg.csrfToken); + + return fetch(getInstallUrl(), { + method: 'POST', + body: formData, + credentials: 'same-origin' + }) + .then(function (resp) { + clearInterval(timer); + return safeJsonResponse(resp); + }) + .catch(function (err) { + clearInterval(timer); + throw err; + }); + } + + // ── Orchestration ─────────────────────────────────────────────────── + + /** + * Playground: browser downloads via CORS proxy (with progress bar), + * then uploads the blob to PHP for extraction. + */ + function playgroundInstall() { + setProgress(2, s.discovering); + + return discoverVersion() + .then(function (version) { + var proxyUrl = buildProxyDownloadUrl(version); + return browserDownloadAndUpload(proxyUrl, version); + }) + .then(function (data) { + if (!handleResult(data)) { + showError(data.message || s.error); + } + }) + .catch(function (err) { + showError(err.message || s.networkError); + }); + } + + /** Download in browser via proxy, then upload blob to PHP. */ + function browserDownloadAndUpload(proxyUrl, version) { + setProgress(5, s.downloading); + return downloadWithProgress(proxyUrl) + .then(function (blob) { + return uploadAndInstall(blob, version); + }); + } + + /** + * Normal: server-side download, then fallback to proxy. + */ + function normalInstall() { + setProgress(2, s.downloading); + + return serverSideInstall() + .then(function (data) { + if (handleResult(data)) return; + + // Server-side failed — try proxy path + return proxyFallback(); + }) + .catch(function () { + return proxyFallback(); + }); + } + + function proxyFallback() { + setProgress(2, s.discovering); + + return discoverVersion() + .then(function (version) { + var proxyUrl = buildProxyDownloadUrl(version); + return browserDownloadAndUpload(proxyUrl, version); + }) + .then(function (data) { + if (!handleResult(data)) { + showError(data.message || s.error); + } + }) + .catch(function (err) { + showError(err.message || s.networkError); + }); + } + + // ── Entry point ───────────────────────────────────────────────────── + + btn.addEventListener('click', function () { + btn.disabled = true; + btn.textContent = s.pleaseWait; + resultDiv.style.display = 'none'; + resultDiv.innerHTML = ''; + progressDiv.style.display = 'block'; + + if (isPlayground()) { + playgroundInstall(); + } else { + normalInstall(); + } + }); +})(); diff --git a/asset/js/omeka-exe-bridge.js b/asset/js/omeka-exe-bridge.js index a09a6be..a5b0ef6 100644 --- a/asset/js/omeka-exe-bridge.js +++ b/asset/js/omeka-exe-bridge.js @@ -162,8 +162,11 @@ var bridge = await waitForBridge(); // Fetch the ELP file + // Use cache: 'no-cache' to force revalidation with the server. + // Without this, the browser may serve a stale cached version after + // the file is updated on the server (heuristic caching). updateLoadScreen('Downloading file...'); - var response = await fetch(elpUrl); + var response = await fetch(elpUrl, { cache: 'no-cache' }); if (!response.ok) { throw new Error('HTTP ' + response.status + ': ' + response.statusText); } @@ -257,19 +260,21 @@ console.log('[Omeka-EXE Bridge] Export complete, size:', blob.size); - // Upload to Omeka-S - var formData = new FormData(); - formData.append('file', blob, 'project.elpx'); + // Upload to Omeka-S. + // Use raw binary body — this works in both standard PHP and php-wasm + // (where multipart/FormData file uploads may not populate $_FILES). + console.log('[Omeka-EXE Bridge] Uploading to:', config.saveEndpoint); + + var saveHeaders = { 'Content-Type': 'application/octet-stream' }; if (config.csrfToken) { - formData.append('csrf', config.csrfToken); + saveHeaders['X-CSRF-Token'] = config.csrfToken; } - console.log('[Omeka-EXE Bridge] Uploading to:', config.saveEndpoint); - var saveResponse = await fetch(config.saveEndpoint, { method: 'POST', credentials: 'same-origin', - body: formData + headers: saveHeaders, + body: blob }); var saveResult = await saveResponse.json(); @@ -283,7 +288,7 @@ window.parent.postMessage({ type: 'exelearning-save-complete', mediaId: config.mediaId, - previewUrl: saveResult.preview_url + contentPath: saveResult.contentPath }, '*'); } } else { diff --git a/blueprint.json b/blueprint.json index acd04ff..f606f6a 100644 --- a/blueprint.json +++ b/blueprint.json @@ -23,7 +23,7 @@ "state": "activate", "source": { "type": "url", - "url": "https://github.com/exelearning/omeka-s-exelearning/releases/download/v4.0.0-beta2/ExeLearning-4.0.0-beta2.zip" + "url": "https://github.com/exelearning/omeka-s-exelearning/archive/refs/heads/main.zip" } } ], diff --git a/claude.md b/claude.md index 0693882..eef4bd2 100644 --- a/claude.md +++ b/claude.md @@ -1,227 +1 @@ -# ExeLearning Module for Omeka S - Security & Architecture - -This document describes the security considerations and system architecture implemented in the ExeLearning module. - -## Architecture Overview - -The module enables viewing and editing of eXeLearning (.elpx) files within Omeka S. The system consists of: - -``` -+------------------+ +-------------------+ +------------------+ -| Admin Interface | | Content Proxy | | Editor (iframe) | -| (media-show) |---->| (ContentController)|-->| (eXeLearning) | -+------------------+ +-------------------+ +------------------+ - | | | - v v v -+------------------+ +-------------------+ +------------------+ -| Modal Editor | | /files/exelearning/| | postMessage API | -| (fullscreen) | | (extracted files) | | (communication) | -+------------------+ +-------------------+ +------------------+ - | | - v v -+------------------+ +------------------+ -| API Controller |<-----------------------------| Bridge JS | -| (save/load) | | (import/export) | -+------------------+ +------------------+ -``` - -## File Storage - -- **Original .elpx files**: Stored in Omeka's standard `/files/original/` directory -- **Extracted content**: Stored in `/files/exelearning/{sha1-hash}/` directories -- **Thumbnails**: Generated and stored as custom thumbnails for media items - -## Security Measures - -### 1. Iframe Sandboxing - -All iframes displaying eXeLearning content use restrictive sandbox attributes: - -```html - -``` - -**Allowed capabilities:** -- `allow-scripts`: Required for interactive content -- `allow-popups`: Some eXeLearning content may need popups -- `allow-popups-to-escape-sandbox`: Popups can function normally - -**Blocked capabilities:** -- `allow-same-origin`: Prevents access to parent page cookies/storage -- `allow-forms`: Prevents form submission to external URLs -- `allow-top-navigation`: Prevents navigation of parent page - -### 2. Content Security Policy (CSP) - -The ContentController adds strict CSP headers for HTML content: - -``` -Content-Security-Policy: - default-src 'self'; - script-src 'self' 'unsafe-inline' 'unsafe-eval'; - style-src 'self' 'unsafe-inline'; - img-src 'self' data: blob:; - media-src 'self' data: blob:; - font-src 'self' data:; - frame-src 'self'; - object-src 'none'; - base-uri 'self' -``` - -This prevents: -- Loading external scripts (XSS mitigation) -- Connecting to external servers -- Embedding external iframes -- Using plugins (Flash, Java, etc.) - -### 3. Secure Content Proxy - -Direct access to `/files/exelearning/` is blocked. All content is served through a PHP proxy (`ContentController::serveAction`): - -**Security validations:** -1. Hash format validation (40 hex characters - SHA1) -2. Path traversal prevention (blocks `..`) -3. File existence verification -4. MIME type detection and Content-Type headers - -```php -// Hash validation -if (!preg_match('/^[a-f0-9]{40}$/', $hash)) { - return $this->notFoundAction(); -} - -// Path traversal prevention -if (strpos($file, '..') !== false) { - return $this->notFoundAction(); -} -``` - -### 4. Additional Security Headers - -``` -X-Frame-Options: SAMEORIGIN -X-Content-Type-Options: nosniff -``` - -- **X-Frame-Options**: Prevents clickjacking by blocking external framing -- **X-Content-Type-Options**: Prevents MIME-sniffing attacks - -### 5. Server Configuration (nginx) - -The module requires nginx rules to: - -1. **Block direct file access:** -```nginx -location ^~ /files/exelearning/ { - return 403; -} -``` - -2. **Route proxy requests to PHP:** -```nginx -location ^~ /exelearning/content/ { - try_files $uri /index.php$is_args$args; -} -``` - -### 6. CSRF Protection - -API endpoints require a valid CSRF key: - -```php -$csrfKey = $data['csrf_key'] ?? $request->getQuery('csrf_key'); -$session = Container::getDefaultManager()->getStorage(); -if (!$session || $csrfKey !== ($session['Omeka\Csrf'] ?? null)) { - return new ApiProblemResponse(new ApiProblem(403, 'Invalid CSRF token')); -} -``` - -### 7. ACL Permissions - -Edit functionality requires proper permissions: - -```php -$acl = $this->getServiceLocator()->get('Omeka\Acl'); -if (!$acl->userIsAllowed('Omeka\Entity\Media', 'update')) { - return new ApiProblemResponse(new ApiProblem(403, 'Permission denied')); -} -``` - -## Communication Flow - -### Parent-Iframe Communication - -Communication uses `postMessage` API with origin validation: - -**Editor to Parent:** -```javascript -window.parent.postMessage({ - type: 'exelearning-bridge-ready' -}, window.location.origin); - -window.parent.postMessage({ - type: 'exelearning-save-complete', - success: true -}, window.location.origin); -``` - -**Parent to Editor:** -```javascript -iframe.contentWindow.postMessage({ - type: 'exelearning-request-save' -}, '*'); -``` - -### Save Flow - -1. User clicks "Save to Omeka" button -2. Parent sends `exelearning-request-save` message -3. Bridge exports ELPX from editor -4. Bridge POSTs to `/api/exelearning/save/{id}` with CSRF token -5. Server validates token, permissions, and saves file -6. Bridge sends `exelearning-save-complete` message -7. Parent closes modal and refreshes preview - -## File Types and MIME Detection - -The module handles various file types within .elpx archives: - -| Extension | MIME Type | -|-----------|-----------| -| .html | text/html | -| .css | text/css | -| .js | application/javascript | -| .json | application/json | -| .png | image/png | -| .jpg/.jpeg | image/jpeg | -| .gif | image/gif | -| .svg | image/svg+xml | -| .mp4 | video/mp4 | -| .webm | video/webm | -| .mp3 | audio/mpeg | -| .ogg | audio/ogg | -| .woff/.woff2 | font/woff, font/woff2 | -| .ttf | font/ttf | -| .pdf | application/pdf | - -## Potential Attack Vectors (Mitigated) - -1. **XSS via uploaded content**: Mitigated by CSP headers and iframe sandboxing -2. **Path traversal**: Mitigated by `..` filtering and hash validation -3. **CSRF attacks**: Mitigated by CSRF token validation -4. **Unauthorized editing**: Mitigated by ACL permission checks -5. **Clickjacking**: Mitigated by X-Frame-Options header -6. **Direct file access**: Mitigated by nginx rules blocking /files/exelearning/ -7. **MIME sniffing**: Mitigated by X-Content-Type-Options header - -## Recommendations for Administrators - -1. Ensure nginx is properly configured with the blocking rules -2. Review CSP headers if specific eXeLearning content requires external resources -3. Keep the module updated for security patches -4. Monitor server logs for suspicious access patterns -5. Consider additional rate limiting on API endpoints +@AGENTS.md \ No newline at end of file diff --git a/config/module.config.php b/config/module.config.php index 402a617..c30cfb3 100644 --- a/config/module.config.php +++ b/config/module.config.php @@ -12,6 +12,9 @@ 'template_path_stack' => [ dirname(__DIR__) . '/view', ], + 'strategies' => [ + 'ViewJsonStrategy', + ], ], 'controllers' => [ @@ -118,6 +121,15 @@ ], ], ], + 'install-editor' => [ + 'type' => Literal::class, + 'options' => [ + 'route' => '/install-editor', + 'defaults' => [ + 'action' => 'installEditor', + ], + ], + ], ], ], 'admin' => [ @@ -137,6 +149,17 @@ ], ], ], + 'exelearning-install' => [ + 'type' => Literal::class, + 'options' => [ + 'route' => '/exelearning/install-editor', + 'defaults' => [ + '__NAMESPACE__' => 'ExeLearning\Controller', + 'controller' => 'Editor', + 'action' => 'installEditor', + ], + ], + ], ], ], ], diff --git a/language/es.po b/language/es.po index dd50179..3d35bc6 100644 --- a/language/es.po +++ b/language/es.po @@ -104,3 +104,107 @@ msgstr "Mostrar botón de edición" #: src/Form/ConfigForm.php:39 msgid "Display the \"Edit in eXeLearning\" button for users with edit permissions." msgstr "Mostrar el botón \"Editar en eXeLearning\" para usuarios con permisos de edición." + +#: Module.php +msgid "Embedded Editor" +msgstr "Editor integrado" + +#: Module.php +msgid "Status" +msgstr "Estado" + +#: Module.php +msgid "Installed" +msgstr "Instalado" + +#: Module.php +msgid "installed on" +msgstr "instalado el" + +#: Module.php +msgid "Update to Latest Version" +msgstr "Actualizar a la última versión" + +#: Module.php +msgid "Not installed" +msgstr "No instalado" + +#: Module.php +msgid "The embedded eXeLearning editor is not installed. You can download and install the latest version automatically from GitHub." +msgstr "El editor integrado de eXeLearning no está instalado. Puedes descargar e instalar la última versión automáticamente desde GitHub." + +#: Module.php +msgid "Download & Install Editor" +msgstr "Descargar e instalar editor" + +#. translators: %s: make build-editor command +#: Module.php +#, php-format +msgid "Developers can also build the editor from source using %s." +msgstr "Los desarrolladores también pueden compilar el editor desde el código fuente usando %s." + +#: src/Controller/EditorController.php +msgid "The embedded eXeLearning editor is not installed. Please install it from the module configuration page." +msgstr "El editor integrado de eXeLearning no está instalado. Por favor, instálalo desde la página de configuración del módulo." + +#: src/Controller/EditorController.php +msgid "You do not have permission to install the editor." +msgstr "No tienes permiso para instalar el editor." + +#: src/Controller/EditorController.php +msgid "Security check failed. Please refresh the page and try again." +msgstr "La verificación de seguridad falló. Por favor, recarga la página e inténtalo de nuevo." + +#. translators: %s: editor version +#: src/Controller/EditorController.php +#, php-format +msgid "eXeLearning editor v%s installed successfully." +msgstr "Editor de eXeLearning v%s instalado correctamente." + +#: src/Service/StaticEditorInstaller.php +msgid "Could not connect to GitHub. Please check your internet connection or install the editor from a release package." +msgstr "No se pudo conectar a GitHub. Por favor, comprueba tu conexión a internet o instala el editor desde un paquete de release." + +#: src/Service/StaticEditorInstaller.php +msgid "Could not parse the latest release information from GitHub." +msgstr "No se pudo analizar la información de la última versión de GitHub." + +#: src/Service/StaticEditorInstaller.php +msgid "Failed to download the editor package. This may happen in browser-based environments. Please install the module from a release package that includes the editor." +msgstr "Error al descargar el paquete del editor. Esto puede ocurrir en entornos basados en navegador. Por favor, instala el módulo desde un paquete de release que incluya el editor." + +#: src/Service/StaticEditorInstaller.php +msgid "The downloaded file is not a valid ZIP archive." +msgstr "El archivo descargado no es un archivo ZIP válido." + +#: src/Service/StaticEditorInstaller.php +msgid "Could not create temporary directory for extraction." +msgstr "No se pudo crear el directorio temporal para la extracción." + +#: src/Service/StaticEditorInstaller.php +msgid "Failed to open the editor package for extraction." +msgstr "Error al abrir el paquete del editor para la extracción." + +#: src/Service/StaticEditorInstaller.php +msgid "The downloaded package does not contain the expected editor files. Could not find index.html." +msgstr "El paquete descargado no contiene los archivos esperados del editor. No se encontró index.html." + +#: src/Service/StaticEditorInstaller.php +msgid "The editor package is missing index.html." +msgstr "Al paquete del editor le falta index.html." + +#: src/Service/StaticEditorInstaller.php +msgid "The editor package is missing expected asset directories (app, libs, or files)." +msgstr "Al paquete del editor le faltan los directorios de recursos esperados (app, libs o files)." + +#: src/Service/StaticEditorInstaller.php +msgid "Could not create the dist directory." +msgstr "No se pudo crear el directorio dist." + +#: src/Service/StaticEditorInstaller.php +msgid "Could not back up the existing editor installation." +msgstr "No se pudo hacer copia de seguridad de la instalación actual del editor." + +#: src/Service/StaticEditorInstaller.php +msgid "Failed to copy editor files to the module directory." +msgstr "Error al copiar los archivos del editor al directorio del módulo." diff --git a/src/Controller/ApiController.php b/src/Controller/ApiController.php index 405bd84..e73c56d 100644 --- a/src/Controller/ApiController.php +++ b/src/Controller/ApiController.php @@ -6,6 +6,7 @@ use Laminas\Mvc\Controller\AbstractActionController; use Laminas\View\Model\JsonModel; use ExeLearning\Service\ElpFileService; +use ExeLearning\Service\StaticEditorInstaller; /** * REST API controller for eXeLearning operations. @@ -23,6 +24,44 @@ public function __construct(ElpFileService $elpService) $this->elpService = $elpService; } + /** + * Build an absolute content proxy URL for the given hash. + * + * Derives the base path from the actual request URI path so that the + * playground prefix (/playground/{uuid}/php83/) is correctly included + * even in PHP-WASM environments where getBasePath() is unreliable. + */ + protected function buildContentUrl(string $hash): string + { + $request = $this->getRequest(); + $uri = $request->getUri(); + $scheme = $uri->getScheme(); + $port = $uri->getPort(); + $serverUrl = $scheme . '://' . $uri->getHost(); + if ($port && !(($scheme === 'http' && $port == 80) || ($scheme === 'https' && $port == 443))) { + $serverUrl .= ':' . $port; + } + $basePath = $this->extractBasePath($uri->getPath()); + return $serverUrl . $basePath . '/exelearning/content/' . $hash . '/index.html'; + } + + /** + * Derive the Omeka base path from the actual request URI path. + * + * Strips everything from the first known Omeka route segment onward. + * Reliable in PHP-WASM where the full URL path is preserved in the URI. + */ + protected function extractBasePath(string $uriPath): string + { + foreach (['/admin/', '/s/', '/api/'] as $marker) { + $pos = strpos($uriPath, $marker); + if ($pos !== false) { + return substr($uriPath, 0, $pos); + } + } + return ''; + } + /** * Create a JSON error response with status code. */ @@ -54,6 +93,15 @@ protected function getMediaOrFail(int $mediaId) * @codeCoverageIgnore */ public function saveAction() + { + try { + return $this->doSave(); + } catch (\Throwable $e) { + return $this->errorResponse(500, $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine()); + } + } + + private function doSave() { $request = $this->getRequest(); @@ -65,8 +113,11 @@ public function saveAction() return $this->errorResponse(401, 'Unauthorized'); } - // Validate CSRF token - $csrfToken = $request->getPost('csrf') ?? $request->getHeaders()->get('X-CSRF-Token')?->getFieldValue(); + $csrfToken = $request->getPost('csrf'); + if (!$csrfToken) { + $csrfHeader = $request->getHeaders()->get('X-CSRF-Token'); + $csrfToken = $csrfHeader ? $csrfHeader->getFieldValue() : null; + } if ($csrfToken) { $csrf = new \Laminas\Validator\Csrf(['name' => 'csrf']); if (!$csrf->isValid($csrfToken)) { @@ -90,34 +141,52 @@ public function saveAction() return $this->errorResponse(403, 'Forbidden'); } - $files = $request->getFiles(); - if (empty($files['file'])) { - return $this->errorResponse(400, 'No file uploaded'); + // Accept file via multipart upload OR raw binary body. + // Raw binary is needed for php-wasm environments where $_FILES is not populated. + $contentType = $request->getHeaders()->get('Content-Type'); + $contentTypeValue = $contentType ? $contentType->getFieldValue() : ''; + $tmpFile = null; + + if (stripos($contentTypeValue, 'application/octet-stream') !== false + || stripos($contentTypeValue, 'application/zip') !== false) { + $body = $request->getContent(); + if (empty($body)) { + return $this->errorResponse(400, 'Empty request body'); + } + $tmpFile = tempnam(sys_get_temp_dir(), 'exelearning-save-'); + if (file_put_contents($tmpFile, $body) === false) { + @unlink($tmpFile); + return $this->errorResponse(500, 'Failed to write request body to temp file'); + } + } else { + $files = $request->getFiles(); + if (!empty($files['file'])) { + if ($files['file']['error'] !== UPLOAD_ERR_OK) { + return $this->errorResponse(400, 'Upload failed: error code ' . $files['file']['error']); + } + $tmpFile = $files['file']['tmp_name']; + } } - $uploadedFile = $files['file']; - if ($uploadedFile['error'] !== UPLOAD_ERR_OK) { - return $this->errorResponse(400, 'Upload failed: error code ' . $uploadedFile['error']); + if (!$tmpFile) { + return $this->errorResponse(400, 'No file uploaded'); } try { // Replace the file - $result = $this->elpService->replaceFile($media, $uploadedFile['tmp_name']); - - // Build secure preview URL using proxy controller - $previewUrl = null; - if ($result['hasPreview']) { - $previewUrl = $this->url()->fromRoute('exelearning-content', [ - 'hash' => $result['hash'], - 'file' => 'index.html', - ]); - } + $result = $this->elpService->replaceFile($media, $tmpFile); + + // Return a relative content path; JS prepends the correct base from + // window.location (PHP cannot see the playground SW scope prefix). + $contentPath = $result['hasPreview'] + ? '/exelearning/content/' . $result['hash'] . '/index.html' + : null; return new JsonModel([ 'success' => true, 'message' => 'File saved successfully', 'media_id' => (int) $mediaId, - 'preview_url' => $previewUrl, + 'contentPath' => $contentPath, ]); } catch (\Exception $e) { return $this->errorResponse(500, 'Save failed: ' . $e->getMessage()); @@ -144,14 +213,11 @@ public function getDataAction(): JsonModel $hash = $this->elpService->getMediaHash($media); $hasPreview = $this->elpService->hasPreview($media); - // Build secure preview URL using proxy controller - $previewUrl = null; - if ($hash && $hasPreview) { - $previewUrl = $this->url()->fromRoute('exelearning-content', [ - 'hash' => $hash, - 'file' => 'index.html', - ]); - } + // Return a relative content path; JS prepends the correct base from + // window.location (PHP cannot see the playground SW scope prefix). + $contentPath = ($hash && $hasPreview) + ? '/exelearning/content/' . $hash . '/index.html' + : null; return new JsonModel([ 'success' => true, @@ -160,7 +226,7 @@ public function getDataAction(): JsonModel 'title' => $media->displayTitle(), 'filename' => $media->filename(), 'hasPreview' => $hasPreview, - 'previewUrl' => $previewUrl, + 'contentPath' => $contentPath, 'teacherModeVisible' => $this->elpService->isTeacherModeVisible($media), ]); } @@ -211,4 +277,190 @@ public function setTeacherModeAction(): JsonModel return $this->errorResponse(500, 'Update failed: ' . $e->getMessage()); } } + + /** + * Install or update the static eXeLearning editor. + * + * Supports three modes: + * - Mode A (download_url): JS provides a CORS-friendly URL (e.g. proxy). + * PHP downloads it directly. Best for Playground (PHP-WASM) where the + * proxy adds CORS headers so PHP's file_get_contents works via browser fetch. + * - Mode B (file upload): Browser downloaded the ZIP and uploads it here. + * - Mode C (server download): No file/URL; PHP downloads from GitHub directly. + * Works in normal Omeka S deployments. + * + * POST /api/exelearning/install-editor + * - csrf: CSRF token + * - download_url (optional): CORS-friendly URL to download ZIP from + * - version (optional): version string + * - file (optional): uploaded ZIP + * + * @return JsonModel + */ + public function installEditorAction() + { + $request = $this->getRequest(); + + if (!$request->isPost()) { + return $this->errorResponse(405, 'Method not allowed'); + } + + if (!$this->identity()) { + return $this->errorResponse(401, 'Unauthorized'); + } + + // Validate CSRF token (POST body, query string, or header) + $csrfToken = $request->getPost('csrf'); + if (!$csrfToken && method_exists($request, 'getQuery')) { + $csrfToken = $request->getQuery('csrf'); + } + if (!$csrfToken) { + $header = $request->getHeaders()->get('X-CSRF-Token'); + if ($header) { + $csrfToken = $header->getFieldValue(); + } + } + if ($csrfToken) { + $csrf = new \Laminas\Validator\Csrf(['name' => 'csrf']); + if (!$csrf->isValid($csrfToken)) { + return $this->errorResponse(403, 'CSRF: Invalid or missing CSRF token'); + } + } + + // Check admin permissions + $acl = $this->getEvent()->getApplication()->getServiceManager()->get('Omeka\Acl'); + if (!$acl->userIsAllowed('Omeka\Entity\Module', 'update')) { + return $this->errorResponse(403, 'Forbidden'); + } + + $installer = new StaticEditorInstaller(); + + // Query params (used by chunked upload and raw body modes) + $action = method_exists($request, 'getQuery') + ? $request->getQuery('action', '') + : ''; + $qVersion = method_exists($request, 'getQuery') + ? $request->getQuery('version', 'unknown') + : 'unknown'; + $uploadId = method_exists($request, 'getQuery') + ? $request->getQuery('upload_id', '') + : ''; + + $downloadUrl = $request->getPost('download_url'); + $files = $request->getFiles(); + $hasUpload = !empty($files['file']) && $files['file']['error'] === UPLOAD_ERR_OK; + + try { + // --- Chunked upload (for PHP-WASM where large single requests fail) --- + if ($action === 'chunk') { + return $this->handleChunk($request, $uploadId); + } + if ($action === 'finalize') { + return $this->handleFinalize($installer, $uploadId, $qVersion); + } + + if ($downloadUrl) { + // PHP downloads from a CORS-friendly URL (e.g. proxy) + $version = $request->getPost('version', 'unknown'); + $tmpFile = $installer->downloadAsset($downloadUrl); + try { + $result = $installer->installFromFile($tmpFile, $version); + } finally { + @unlink($tmpFile); + } + } elseif ($hasUpload) { + // Browser uploaded the ZIP via multipart + $version = $request->getPost('version', 'unknown'); + $result = $installer->installFromFile($files['file']['tmp_name'], $version); + } else { + // Server downloads from GitHub directly + $result = $installer->installLatestEditor(); + } + + // Save settings + $settings = $this->getEvent()->getApplication()->getServiceManager()->get('Omeka\Settings'); + $settings->set(StaticEditorInstaller::SETTING_VERSION, $result['version']); + $settings->set(StaticEditorInstaller::SETTING_INSTALLED_AT, $result['installed_at']); + + return new JsonModel([ + 'success' => true, + 'message' => sprintf('eXeLearning editor v%s installed successfully.', $result['version']), + 'version' => $result['version'], + 'installed_at' => $result['installed_at'], + ]); + } catch (\Throwable $e) { + return $this->errorResponse(500, $e->getMessage()); + } + } + + /** + * Receive a chunk of the editor ZIP and append to a temp file. + * + * @codeCoverageIgnore + */ + private function handleChunk($request, string $uploadId): JsonModel + { + if (!$uploadId || !preg_match('/^[a-zA-Z0-9]{8,32}$/', $uploadId)) { + return $this->errorResponse(400, 'Invalid upload_id'); + } + + $tmpFile = sys_get_temp_dir() . '/exelearning-chunk-' . $uploadId; + $body = $request->getContent(); + if (empty($body)) { + return $this->errorResponse(400, 'Empty chunk'); + } + + // Append chunk to temp file + $written = file_put_contents($tmpFile, $body, FILE_APPEND); + if ($written === false) { + return $this->errorResponse(500, 'Failed to write chunk'); + } + + $totalSize = file_exists($tmpFile) ? filesize($tmpFile) : 0; + + return new JsonModel([ + 'success' => true, + 'received' => $written, + 'total_size' => $totalSize, + ]); + } + + /** + * Finalize a chunked upload: validate, extract, and install. + * + * @codeCoverageIgnore + */ + private function handleFinalize( + StaticEditorInstaller $installer, + string $uploadId, + string $version + ): JsonModel { + if (!$uploadId || !preg_match('/^[a-zA-Z0-9]{8,32}$/', $uploadId)) { + return $this->errorResponse(400, 'Invalid upload_id'); + } + + $tmpFile = sys_get_temp_dir() . '/exelearning-chunk-' . $uploadId; + if (!file_exists($tmpFile)) { + return $this->errorResponse(400, 'No chunks received for this upload_id'); + } + + try { + $result = $installer->installFromFile($tmpFile, $version); + + $settings = $this->getEvent()->getApplication()->getServiceManager()->get('Omeka\Settings'); + $settings->set(StaticEditorInstaller::SETTING_VERSION, $result['version']); + $settings->set(StaticEditorInstaller::SETTING_INSTALLED_AT, $result['installed_at']); + + return new JsonModel([ + 'success' => true, + 'message' => sprintf('eXeLearning editor v%s installed successfully.', $result['version']), + 'version' => $result['version'], + 'installed_at' => $result['installed_at'], + ]); + } catch (\Throwable $e) { + return $this->errorResponse(500, $e->getMessage()); + } finally { + @unlink($tmpFile); + } + } } diff --git a/src/Controller/EditorController.php b/src/Controller/EditorController.php index bbbb45d..438478a 100644 --- a/src/Controller/EditorController.php +++ b/src/Controller/EditorController.php @@ -6,6 +6,7 @@ use Laminas\Mvc\Controller\AbstractActionController; use Laminas\View\Model\ViewModel; use ExeLearning\Service\ElpFileService; +use ExeLearning\Service\StaticEditorInstaller; /** * Controller for the eXeLearning editor page. @@ -32,7 +33,6 @@ public function __construct(ElpFileService $elpService) */ public function editAction() { - // Check authentication $user = $this->identity(); if (!$user) { return $this->redirect()->toRoute('login'); @@ -43,7 +43,6 @@ public function editAction() return $this->redirect()->toRoute('admin'); } - // Get the media $api = $this->api(); try { $media = $api->read('media', $mediaId)->getContent(); @@ -52,14 +51,12 @@ public function editAction() return $this->redirect()->toRoute('admin'); } - // Check permissions $acl = $this->getEvent()->getApplication()->getServiceManager()->get('Omeka\Acl'); if (!$acl->userIsAllowed('Omeka\Entity\Media', 'update')) { $this->messenger()->addError('You do not have permission to edit media.'); return $this->redirect()->toRoute('admin'); } - // Check if it's an eXeLearning file $filename = $media->filename(); $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); if (!in_array($extension, ['elpx', 'zip'])) { @@ -67,16 +64,17 @@ public function editAction() return $this->redirect()->toRoute('admin'); } - // Check if editor exists $editorPath = dirname(__DIR__, 2) . '/dist/static/index.html'; if (!file_exists($editorPath)) { - $this->messenger()->addError( - 'eXeLearning editor not found. Please run "make build-editor" in the module directory.' + $this->messenger()->addWarning( + $this->translate('The embedded eXeLearning editor is not installed. Please install it from the module configuration page.') // @translate ); - return $this->redirect()->toRoute('admin'); + return $this->redirect()->toRoute('admin/default', [ + 'controller' => 'module', + 'action' => 'configure', + ], ['query' => ['id' => 'ExeLearning']]); } - // Build configuration for the editor $uri = $this->getRequest()->getUri(); $port = $uri->getPort(); $serverUrl = $uri->getScheme() . '://' . $uri->getHost(); @@ -84,9 +82,8 @@ public function editAction() if ($port && !(($uri->getScheme() === 'http' && $port == 80) || ($uri->getScheme() === 'https' && $port == 443))) { $serverUrl .= ':' . $port; } - $basePath = $this->getRequest()->getBasePath(); + $basePath = $this->extractBasePath($uri->getPath()); - // Generate CSRF token $csrf = new \Laminas\Form\Element\Csrf('csrf'); $csrfToken = $csrf->getValue(); @@ -96,7 +93,7 @@ public function editAction() 'elpUrl' => $media->originalUrl(), 'projectId' => 'omeka-media-' . $mediaId, 'saveEndpoint' => $serverUrl . $basePath . '/api/exelearning/save/' . $mediaId, - 'editorBaseUrl' => $serverUrl . $basePath . '/modules/ExeLearning/asset/static', + 'editorBaseUrl' => $serverUrl . $basePath . '/modules/ExeLearning/dist/static', 'csrfToken' => $csrfToken, 'locale' => substr($this->settings()->get('locale', 'en_US'), 0, 2), 'userName' => $user->getName(), @@ -113,7 +110,6 @@ public function editAction() ], ]; - // Use terminal view model to output full HTML $view = new ViewModel([ 'media' => $media, 'config' => $config, @@ -126,6 +122,25 @@ public function editAction() return $view; } + /** + * Derive the Omeka base path from the actual request URI path. + * + * Strips everything from the first known Omeka route segment onward. + * Reliable in PHP-WASM where the full URL path is preserved in the URI. + * + * @codeCoverageIgnore + */ + protected function extractBasePath(string $uriPath): string + { + foreach (['/admin/', '/s/', '/api/'] as $marker) { + $pos = strpos($uriPath, $marker); + if ($pos !== false) { + return substr($uriPath, 0, $pos); + } + } + return ''; + } + /** * Index action - redirect to admin. * @@ -135,4 +150,205 @@ public function indexAction() { return $this->redirect()->toRoute('admin'); } + + /** + * Install or update the static eXeLearning editor. + * + * Supports chunked upload (for PHP-WASM), file upload, download_url, + * or server-side download from GitHub. + * + * @return JsonModel + * + * @codeCoverageIgnore + */ + public function installEditorAction() + { + $request = $this->getRequest(); + + if (!$request->isPost()) { + return $this->jsonError(405, 'Method not allowed'); + } + + if (!$this->identity()) { + return $this->jsonError(401, 'Unauthorized'); + } + + // Validate CSRF token (POST body, query string, or header) + $csrfToken = $request->getPost('csrf'); + if (!$csrfToken && method_exists($request, 'getQuery')) { + $csrfToken = $request->getQuery('csrf'); + } + if (!$csrfToken) { + $header = $request->getHeaders()->get('X-CSRF-Token'); + if ($header && $header !== false) { + $csrfToken = $header->getFieldValue(); + } + } + if ($csrfToken) { + $csrf = new \Laminas\Validator\Csrf(['name' => 'csrf']); + if (!$csrf->isValid($csrfToken)) { + return $this->jsonError(403, 'CSRF: Invalid or missing CSRF token'); + } + } + + $installer = new StaticEditorInstaller(); + + // Query params (used by chunked upload) + $qAction = method_exists($request, 'getQuery') + ? $request->getQuery('action', '') + : ''; + $qVersion = method_exists($request, 'getQuery') + ? $request->getQuery('version', 'unknown') + : 'unknown'; + $uploadId = method_exists($request, 'getQuery') + ? $request->getQuery('upload_id', '') + : ''; + + $downloadUrl = $request->getPost('download_url'); + $files = $request->getFiles(); + $hasUpload = !empty($files['file']) && $files['file']['error'] === UPLOAD_ERR_OK; + + try { + // Chunked upload + if ($qAction === 'chunk') { + return $this->handleChunk($request, $uploadId); + } + if ($qAction === 'finalize') { + return $this->handleFinalize($installer, $uploadId, $qVersion); + } + + $contentType = $request->getHeaders()->get('Content-Type'); + if ($contentType && strpos($contentType->getFieldValue(), 'application/octet-stream') !== false) { + // Raw binary body + $tmpFile = tempnam(sys_get_temp_dir(), 'exelearning-upload-'); + $body = $request->getContent(); + if (empty($body)) { + @unlink($tmpFile); + return $this->jsonError(400, 'Empty request body'); + } + if (file_put_contents($tmpFile, $body) === false) { + @unlink($tmpFile); + return $this->jsonError(500, 'Failed to write request body to temp file'); + } + try { + $result = $installer->installFromFile($tmpFile, $qVersion); + } finally { + @unlink($tmpFile); + } + } elseif ($downloadUrl) { + $version = $request->getPost('version', 'unknown'); + $tmpFile = $installer->downloadAsset($downloadUrl); + try { + $result = $installer->installFromFile($tmpFile, $version); + } finally { + @unlink($tmpFile); + } + } elseif ($hasUpload) { + $version = $request->getPost('version', 'unknown'); + $result = $installer->installFromFile($files['file']['tmp_name'], $version); + } else { + $result = $installer->installLatestEditor(); + } + + $settings = $this->getEvent()->getApplication()->getServiceManager()->get('Omeka\Settings'); + $settings->set(StaticEditorInstaller::SETTING_VERSION, $result['version']); + $settings->set(StaticEditorInstaller::SETTING_INSTALLED_AT, $result['installed_at']); + + return $this->jsonResponse([ + 'success' => true, + 'message' => sprintf('eXeLearning editor v%s installed successfully.', $result['version']), + 'version' => $result['version'], + 'installed_at' => $result['installed_at'], + ]); + } catch (\Throwable $e) { + return $this->jsonError(500, $e->getMessage()); + } + } + + /** + * @codeCoverageIgnore + */ + private function handleChunk($request, string $uploadId): \Laminas\Http\Response + { + if (!$uploadId || !preg_match('/^[a-zA-Z0-9]{8,32}$/', $uploadId)) { + return $this->jsonError(400, 'Invalid upload_id'); + } + + $tmpFile = sys_get_temp_dir() . '/exelearning-chunk-' . $uploadId; + $body = $request->getContent(); + if (empty($body)) { + return $this->jsonError(400, 'Empty chunk'); + } + + $written = file_put_contents($tmpFile, $body, FILE_APPEND); + if ($written === false) { + return $this->jsonError(500, 'Failed to write chunk'); + } + + return $this->jsonResponse([ + 'success' => true, + 'received' => $written, + 'total_size' => file_exists($tmpFile) ? filesize($tmpFile) : 0, + ]); + } + + /** + * @codeCoverageIgnore + */ + private function handleFinalize( + StaticEditorInstaller $installer, + string $uploadId, + string $version + ): \Laminas\Http\Response { + if (!$uploadId || !preg_match('/^[a-zA-Z0-9]{8,32}$/', $uploadId)) { + return $this->jsonError(400, 'Invalid upload_id'); + } + + $tmpFile = sys_get_temp_dir() . '/exelearning-chunk-' . $uploadId; + if (!file_exists($tmpFile)) { + return $this->jsonError(400, 'No chunks received for this upload_id'); + } + + try { + $result = $installer->installFromFile($tmpFile, $version); + + $settings = $this->getEvent()->getApplication()->getServiceManager()->get('Omeka\Settings'); + $settings->set(StaticEditorInstaller::SETTING_VERSION, $result['version']); + $settings->set(StaticEditorInstaller::SETTING_INSTALLED_AT, $result['installed_at']); + + return $this->jsonResponse([ + 'success' => true, + 'message' => sprintf('eXeLearning editor v%s installed successfully.', $result['version']), + 'version' => $result['version'], + 'installed_at' => $result['installed_at'], + ]); + } catch (\Throwable $e) { + return $this->jsonError(500, $e->getMessage()); + } finally { + @unlink($tmpFile); + } + } + + /** + * Return a JSON response directly, bypassing the admin view layer. + * Admin routes use ViewModel rendering which breaks JsonModel. + * + * @codeCoverageIgnore + */ + private function jsonResponse(array $data, int $statusCode = 200): \Laminas\Http\Response + { + $response = $this->getResponse(); + $response->setStatusCode($statusCode); + $response->getHeaders()->addHeaderLine('Content-Type', 'application/json'); + $response->setContent(json_encode($data)); + return $response; + } + + /** + * @codeCoverageIgnore + */ + private function jsonError(int $statusCode, string $message): \Laminas\Http\Response + { + return $this->jsonResponse(['success' => false, 'message' => $message], $statusCode); + } } diff --git a/src/Media/FileRenderer/ExeLearningRenderer.php b/src/Media/FileRenderer/ExeLearningRenderer.php index c08b218..3350d78 100644 --- a/src/Media/FileRenderer/ExeLearningRenderer.php +++ b/src/Media/FileRenderer/ExeLearningRenderer.php @@ -18,12 +18,17 @@ class ExeLearningRenderer implements RendererInterface /** @var ElpFileService */ protected $elpService; + /** @var \Laminas\Http\Request */ + protected $request; + /** * @param ElpFileService $elpService + * @param \Laminas\Http\Request $request */ - public function __construct(ElpFileService $elpService) + public function __construct(ElpFileService $elpService, \Laminas\Http\Request $request) { $this->elpService = $elpService; + $this->request = $request; } /** @@ -50,17 +55,18 @@ public function render(PhpRenderer $view, MediaRepresentation $media, array $opt if (!$hash || !$hasPreview) { return $this->renderFallback($view, $media); } - } catch (\Exception $e) { + } catch (\Throwable $e) { return $this->renderFallback($view, $media); } // Get configuration $config = $this->getConfig($view); - // Build secure preview URL via proxy controller - $previewUrl = $view->url('exelearning-content', ['hash' => $hash, 'file' => 'index.html']); + // Relative path; JS constructs the full URL from window.location so the + // playground SW scope prefix is always included (PHP cannot see it). + $contentPath = '/exelearning/content/' . $hash . '/index.html'; if (!$this->isTeacherModeVisible($media)) { - $previewUrl .= '?teacher_mode_visible=0'; + $contentPath .= '?teacher_mode_visible=0'; } // Load assets @@ -71,6 +77,8 @@ public function render(PhpRenderer $view, MediaRepresentation $media, array $opt $view->assetUrl('js/exelearning-viewer.js', 'ExeLearning') ); + $iframeId = 'exelearning-iframe-' . $media->id(); + // Build HTML $html = '

'; @@ -88,7 +96,7 @@ public function render(PhpRenderer $view, MediaRepresentation $media, array $opt // Fullscreen button $html .= ''; @@ -109,18 +117,11 @@ public function render(PhpRenderer $view, MediaRepresentation $media, array $opt $html .= '
'; // toolbar-actions $html .= ''; // toolbar - // Iframe with security sandbox - // sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox" prevents: - // - Access to parent page DOM - // - Access to cookies/localStorage from parent origin - // - Form submissions to external sites - // - Running plugins - // While allowing: - // - JavaScript execution (needed for eXeLearning interactivity) - // - Opening popups for external links + // Iframe — src is set by inline JS so the playground SW scope prefix + // from window.location is correctly prepended to the content path. $html .= ''; + $html .= ''; + $html .= ''; // exelearning-viewer return $html; @@ -180,11 +189,42 @@ protected function canEdit(PhpRenderer $view, MediaRepresentation $media): bool } /** - * Check if media is an eXeLearning file. + * Build an absolute content proxy URL for the given hash. * - * @param MediaRepresentation $media - * @return bool + * Derives the base path from the actual request URI path so that the + * playground prefix (/playground/{uuid}/php83/) is correctly included + * even in PHP-WASM environments where getBasePath() is unreliable. + */ + protected function buildContentUrl(string $hash): string + { + $uri = $this->request->getUri(); + $scheme = $uri->getScheme(); + $port = $uri->getPort(); + $serverUrl = $scheme . '://' . $uri->getHost(); + if ($port && !(($scheme === 'http' && $port == 80) || ($scheme === 'https' && $port == 443))) { + $serverUrl .= ':' . $port; + } + $basePath = $this->extractBasePath($uri->getPath()); + return $serverUrl . $basePath . '/exelearning/content/' . $hash . '/index.html'; + } + + /** + * Derive the Omeka base path from the actual request URI path. + * + * Strips everything from the first known Omeka route segment onward. + * Reliable in PHP-WASM where the full URL path is preserved in the URI. */ + protected function extractBasePath(string $uriPath): string + { + foreach (['/admin/', '/s/', '/api/'] as $marker) { + $pos = strpos($uriPath, $marker); + if ($pos !== false) { + return substr($uriPath, 0, $pos); + } + } + return ''; + } + /** * Determine whether teacher mode toggler should be visible. */ diff --git a/src/Media/FileRenderer/ExeLearningRendererFactory.php b/src/Media/FileRenderer/ExeLearningRendererFactory.php index f580795..7fdf3e0 100644 --- a/src/Media/FileRenderer/ExeLearningRendererFactory.php +++ b/src/Media/FileRenderer/ExeLearningRendererFactory.php @@ -11,14 +11,8 @@ class ExeLearningRendererFactory implements FactoryInterface { public function __invoke(ContainerInterface $services, $requestedName, array $options = null) { - error_log('[ExeLearning RendererFactory] Creating renderer...'); - try { - $elpService = $services->get(ElpFileService::class); - error_log('[ExeLearning RendererFactory] ElpFileService obtained successfully'); - return new ExeLearningRenderer($elpService); - } catch (\Exception $e) { - error_log(sprintf('[ExeLearning RendererFactory] ERROR: %s', $e->getMessage())); - throw $e; - } + $elpService = $services->get(ElpFileService::class); + $request = $services->get('Request'); + return new ExeLearningRenderer($elpService, $request); } } diff --git a/src/Service/ElpFileServiceFactory.php b/src/Service/ElpFileServiceFactory.php index 4ac974d..f8808f0 100644 --- a/src/Service/ElpFileServiceFactory.php +++ b/src/Service/ElpFileServiceFactory.php @@ -14,19 +14,26 @@ public function __invoke(ContainerInterface $services, $requestedName, array $op $entityManager = $services->get('Omeka\EntityManager'); $logger = $services->get('Omeka\Logger'); - // Get Omeka files path from the file store configuration - $fileStore = $services->get('Omeka\File\Store'); - - // Try to get the base path from the local file store $filesPath = null; - if (method_exists($fileStore, 'getLocalPath')) { - $filesPath = dirname($fileStore->getLocalPath('')); - } - // Fallback: try config - if (!$filesPath) { + // Try config first (most reliable, works in Playground too) + try { $config = $services->get('Config'); $filesPath = $config['file_store']['local']['base_path'] ?? null; + } catch (\Throwable $e) { + // ignore + } + + // Try the file store service + if (!$filesPath) { + try { + $fileStore = $services->get('Omeka\File\Store'); + if (method_exists($fileStore, 'getLocalPath')) { + $filesPath = dirname($fileStore->getLocalPath('')); + } + } catch (\Throwable $e) { + // ignore - file store may not be available in Playground + } } // Fallback: use OMEKA_PATH @@ -35,7 +42,6 @@ public function __invoke(ContainerInterface $services, $requestedName, array $op } // In Docker with volume, files are typically in /var/www/html/volume/files - // Check if that path exists and use it instead $volumePath = '/var/www/html/volume/files'; if (is_dir($volumePath)) { $filesPath = $volumePath; @@ -44,8 +50,6 @@ public function __invoke(ContainerInterface $services, $requestedName, array $op // Extracted eXeLearning content goes in /files/exelearning/ $basePath = $filesPath . '/exelearning'; - $logger->info(sprintf('[ExeLearning] ElpFileService initialized with basePath=%s, filesPath=%s', $basePath, $filesPath)); - return new ElpFileService($api, $entityManager, $basePath, $filesPath, $logger); } } diff --git a/src/Service/StaticEditorInstaller.php b/src/Service/StaticEditorInstaller.php new file mode 100644 index 0000000..f838b85 --- /dev/null +++ b/src/Service/StaticEditorInstaller.php @@ -0,0 +1,486 @@ +discoverLatestVersion(); + $assetUrl = $this->getAssetUrl($version); + + $tmpFile = $this->downloadAsset($assetUrl); + + try { + $this->validateZip($tmpFile); + $tmpDir = $this->extractZip($tmpFile); + } finally { + $this->cleanupFile($tmpFile); + } + + try { + $sourceDir = $this->normalizeExtraction($tmpDir); + $this->validateEditorContents($sourceDir); + $this->safeInstall($sourceDir); + } finally { + $this->cleanupDirectory($tmpDir); + } + + return [ + 'version' => $version, + 'installed_at' => date('Y-m-d H:i:s'), + ]; + } + + /** + * Install the editor from a local ZIP file (e.g. uploaded by the browser). + * + * @param string $zipPath Path to the ZIP file + * @param string $version Version string + * @return array{version: string, installed_at: string} + * @throws \RuntimeException on failure + */ + public function installFromFile(string $zipPath, string $version): array + { + $this->validateZip($zipPath); + $tmpDir = $this->extractZip($zipPath); + + try { + $sourceDir = $this->normalizeExtraction($tmpDir); + $this->validateEditorContents($sourceDir); + $this->safeInstall($sourceDir); + } finally { + $this->cleanupDirectory($tmpDir); + } + + return [ + 'version' => $version, + 'installed_at' => date('Y-m-d H:i:s'), + ]; + } + + /** + * Discover the latest release version from GitHub. + * + * @throws \RuntimeException + * + * @codeCoverageIgnore + */ + public function discoverLatestVersion(): string + { + $context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'header' => implode("\r\n", [ + 'Accept: application/vnd.github.v3+json', + 'User-Agent: OmekaS-ExeLearning-Module', + ]), + 'timeout' => 30, + ], + ]); + + $response = @file_get_contents(self::proxyUrl(self::GITHUB_API_URL), false, $context); + if ($response === false) { + // Try jsDelivr as fallback (works in PHP-WASM / Playground environments where direct GitHub access fails due to CORS). + $response = @file_get_contents(self::proxyUrl(self::JSDELIVR_API_URL), false, $context); + } + if ($response === false) { + throw new \RuntimeException( + 'Could not connect to GitHub. Please check your internet connection or install the editor from a release package.' // @translate + ); + } + + $data = json_decode($response, true); + if (!is_array($data)) { + throw new \RuntimeException( + 'Could not parse the latest release information from GitHub.' // @translate + ); + } + + // GitHub API returns { tag_name: "v4.0.0" } + // jsDelivr API returns { version: "4.0.0-beta2" } + $tagName = $data['tag_name'] ?? $data['version'] ?? ''; + if (empty($tagName)) { + throw new \RuntimeException( + 'Could not parse the latest release information from GitHub.' // @translate + ); + } + + $version = ltrim($tagName, 'v'); + + if (!preg_match('/^\d+\.\d+/', $version)) { + throw new \RuntimeException( + sprintf('Unexpected release tag format: %s', $data['tag_name']) // @translate + ); + } + + return $version; + } + + /** + * Build the download URL for the static editor asset. + */ + public function getAssetUrl(string $version): string + { + $filename = self::ASSET_PREFIX . $version . '.zip'; + return 'https://github.com/exelearning/exelearning/releases/download/v' . $version . '/' . $filename; + } + + /** + * Download the asset ZIP file to a temp location. + * + * @throws \RuntimeException + * + * @codeCoverageIgnore + */ + public function downloadAsset(string $url): string + { + $tmpFile = tempnam(sys_get_temp_dir(), 'exelearning-editor-'); + if ($tmpFile === false) { + throw new \RuntimeException( + 'Could not create temporary file for download.' // @translate + ); + } + + $context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'header' => 'User-Agent: OmekaS-ExeLearning-Module', + 'timeout' => 300, + 'follow_location' => true, + ], + ]); + + $content = @file_get_contents(self::proxyUrl($url), false, $context); + + // Fallback: try jsDelivr CDN mirror for CORS-restricted environments (PHP-WASM). + if ($content === false && preg_match('#/download/v([^/]+)/#', $url, $m)) { + $tag = 'v' . $m[1]; + $jsdelivrUrl = self::JSDELIVR_CDN_BASE . '@' . $tag . '/dist/static.zip'; + $content = @file_get_contents(self::proxyUrl($jsdelivrUrl), false, $context); + } + + if ($content === false) { + $this->cleanupFile($tmpFile); + throw new \RuntimeException( + 'Failed to download the editor package.' + . ' This may happen in browser-based environments.' + . ' Please install the module from a release package that includes the editor.' // @translate + ); + } + + if (file_put_contents($tmpFile, $content) === false) { + $this->cleanupFile($tmpFile); + throw new \RuntimeException('Failed to write downloaded content to temp file.'); + } + + return $tmpFile; + } + + /** + * Validate that a file is a ZIP archive by checking PK magic bytes. + * + * @throws \RuntimeException + */ + public function validateZip(string $filePath): void + { + $header = file_get_contents($filePath, false, null, 0, 4); + if ($header !== "PK\x03\x04") { + throw new \RuntimeException( + 'The downloaded file is not a valid ZIP archive.' // @translate + ); + } + } + + /** + * Extract a ZIP file to a temporary directory. + * + * @throws \RuntimeException + */ + public function extractZip(string $zipFile): string + { + $tmpDir = sys_get_temp_dir() . '/exelearning-editor-' . bin2hex(random_bytes(8)); + if (!mkdir($tmpDir, 0755, true)) { + throw new \RuntimeException( + 'Could not create temporary directory for extraction.' // @translate + ); + } + + $zip = new \ZipArchive(); + if ($zip->open($zipFile) !== true) { + $this->cleanupDirectory($tmpDir); + throw new \RuntimeException( + 'Failed to open the editor package for extraction.' // @translate + ); + } + + $zip->extractTo($tmpDir); + $zip->close(); + + return $tmpDir; + } + + /** + * Normalize extraction layout. + * + * The ZIP may contain files directly or inside a top-level directory. + * + * @throws \RuntimeException + */ + public function normalizeExtraction(string $tmpDir): string + { + if (file_exists($tmpDir . '/index.html')) { + return $tmpDir; + } + + $entries = array_diff(scandir($tmpDir), ['.', '..']); + if (count($entries) === 1) { + $singleEntry = $tmpDir . '/' . reset($entries); + if (is_dir($singleEntry) && file_exists($singleEntry . '/index.html')) { + return $singleEntry; + } + } + + // Check one more level deep. + foreach ($entries as $entry) { + $entryPath = $tmpDir . '/' . $entry; + if (is_dir($entryPath)) { + $subEntries = array_diff(scandir($entryPath), ['.', '..']); + if (count($subEntries) === 1) { + $subEntry = $entryPath . '/' . reset($subEntries); + if (is_dir($subEntry) && file_exists($subEntry . '/index.html')) { + return $subEntry; + } + } + } + } + + throw new \RuntimeException( + 'The downloaded package does not contain the expected editor files. Could not find index.html.' // @translate + ); + } + + /** + * Validate that extracted contents look like a valid static editor. + * + * @throws \RuntimeException + */ + public function validateEditorContents(string $sourceDir): void + { + if (!file_exists($sourceDir . '/index.html')) { + throw new \RuntimeException( + 'The editor package is missing index.html.' // @translate + ); + } + + $expectedDirs = ['app', 'libs', 'files']; + $foundDir = false; + foreach ($expectedDirs as $dir) { + if (is_dir($sourceDir . '/' . $dir)) { + $foundDir = true; + break; + } + } + + if (!$foundDir) { + throw new \RuntimeException( + 'The editor package is missing expected asset directories (app, libs, or files).' // @translate + ); + } + } + + /** + * Install the editor with rollback on failure. + * + * @throws \RuntimeException + * + * @codeCoverageIgnore + */ + public function safeInstall(string $sourceDir): void + { + $targetDir = self::getEditorPath(); + $parentDir = dirname($targetDir); + $backupDir = $parentDir . '/static-backup-' . time(); + + if (!is_dir($parentDir) && !mkdir($parentDir, 0755, true)) { + throw new \RuntimeException( + 'Could not create the dist directory.' // @translate + ); + } + + $hadExisting = is_dir($targetDir); + if ($hadExisting) { + if (!rename($targetDir, $backupDir)) { + throw new \RuntimeException( + 'Could not back up the existing editor installation.' // @translate + ); + } + } + + // Try rename first (fast, same-filesystem). Fall back to copy. + $installed = @rename($sourceDir, $targetDir); + + if (!$installed) { + $installed = $this->recursiveCopy($sourceDir, $targetDir); + } + + if (!$installed) { + if ($hadExisting && is_dir($backupDir)) { + if (is_dir($targetDir)) { + $this->cleanupDirectory($targetDir); + } + rename($backupDir, $targetDir); + } + throw new \RuntimeException( + 'Failed to copy editor files to the module directory.' // @translate + ); + } + + if ($hadExisting && is_dir($backupDir)) { + $this->cleanupDirectory($backupDir); + } + } + + /** + * Recursively copy a directory. + * + * @codeCoverageIgnore + */ + private function recursiveCopy(string $source, string $dest): bool + { + if (!is_dir($source)) { + return false; + } + + if (!is_dir($dest) && !mkdir($dest, 0755, true)) { + return false; + } + + $dir = opendir($source); + if (!$dir) { + return false; + } + + while (($entry = readdir($dir)) !== false) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $sourcePath = $source . '/' . $entry; + $destPath = $dest . '/' . $entry; + + if (is_dir($sourcePath)) { + if (!$this->recursiveCopy($sourcePath, $destPath)) { + closedir($dir); + return false; + } + } else { + if (!copy($sourcePath, $destPath)) { + closedir($dir); + return false; + } + } + } + + closedir($dir); + return true; + } + + /** + * Clean up a temporary file. + * + * @codeCoverageIgnore + */ + private function cleanupFile(string $file): void + { + // Check before unlink: in Playground (PHP-WASM), the file may have been + // cleaned up by a concurrent process between download and installation. + if (file_exists($file)) { + @unlink($file); + } + } + + /** + * Clean up a temporary directory recursively. + * + * @codeCoverageIgnore + */ + public function cleanupDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $files = array_diff(scandir($dir), ['.', '..']); + foreach ($files as $file) { + $path = $dir . '/' . $file; + if (is_dir($path)) { + $this->cleanupDirectory($path); + } else { + @unlink($path); + } + } + + @rmdir($dir); + } +} diff --git a/test/ExeLearningTest/Controller/ApiControllerTest.php b/test/ExeLearningTest/Controller/ApiControllerTest.php index 5062dc5..4890ea0 100644 --- a/test/ExeLearningTest/Controller/ApiControllerTest.php +++ b/test/ExeLearningTest/Controller/ApiControllerTest.php @@ -498,6 +498,8 @@ public function get($name) { return null; } public function getFiles() { return ['file' => ['error' => UPLOAD_ERR_OK, 'tmp_name' => '/tmp/test.elpx']]; } + public function getUri() { return new \Laminas\Uri\Http(); } + public function getBasePath(): string { return ''; } }; $identity = new class { @@ -527,7 +529,8 @@ public function getName(): string { return 'Test User'; } $this->assertTrue($result->getVariables()['success']); $this->assertEquals('File saved successfully', $result->getVariables()['message']); $this->assertEquals(123, $result->getVariables()['media_id']); - $this->assertNotNull($result->getVariables()['preview_url']); + $this->assertNotNull($result->getVariables()['contentPath']); + $this->assertStringContainsString('/exelearning/content/', $result->getVariables()['contentPath']); } public function testSaveActionSuccessWithoutPreview(): void @@ -570,7 +573,7 @@ public function getName(): string { return 'Test User'; } $this->assertInstanceOf(JsonModel::class, $result); $this->assertTrue($result->getVariables()['success']); - $this->assertNull($result->getVariables()['preview_url']); + $this->assertNull($result->getVariables()['contentPath']); } public function testSaveActionReturns500OnException(): void @@ -648,7 +651,8 @@ public function testGetDataActionSuccessWithPreview(): void $this->assertEquals('Test ELP', $result->getVariables()['title']); $this->assertEquals('test.elpx', $result->getVariables()['filename']); $this->assertTrue($result->getVariables()['hasPreview']); - $this->assertNotNull($result->getVariables()['previewUrl']); + $this->assertNotNull($result->getVariables()['contentPath']); + $this->assertStringContainsString('/exelearning/content/', $result->getVariables()['contentPath']); } public function testGetDataActionSuccessWithoutPreview(): void @@ -673,7 +677,7 @@ public function testGetDataActionSuccessWithoutPreview(): void $this->assertInstanceOf(JsonModel::class, $result); $this->assertTrue($result->getVariables()['success']); $this->assertFalse($result->getVariables()['hasPreview']); - $this->assertNull($result->getVariables()['previewUrl']); + $this->assertNull($result->getVariables()['contentPath']); } public function testGetDataActionWithEmptyHash(): void @@ -696,8 +700,8 @@ public function testGetDataActionWithEmptyHash(): void $result = $this->controller->getDataAction(); $this->assertInstanceOf(JsonModel::class, $result); - // Empty hash is falsy, so previewUrl should be null - $this->assertNull($result->getVariables()['previewUrl']); + // Empty hash is falsy, so contentPath should be null + $this->assertNull($result->getVariables()['contentPath']); } // ========================================================================= @@ -1003,6 +1007,269 @@ public function getName(): string { return 'Test User'; } $this->assertFalse($result->getVariables()['teacherModeVisible']); } + // ========================================================================= + // installEditorAction() tests + // ========================================================================= + + public function testInstallEditorActionRequiresPostMethod(): void + { + $request = new class { + public function isPost(): bool { return false; } + }; + + $this->controller->setRequest($request); + $result = $this->controller->installEditorAction(); + + $this->assertInstanceOf(JsonModel::class, $result); + $this->assertEquals(405, $this->controller->getResponse()->getStatusCode()); + $this->assertEquals('Method not allowed', $result->getVariables()['message']); + } + + public function testInstallEditorActionRequiresAuthentication(): void + { + $request = new class { + public function isPost(): bool { return true; } + }; + + $this->controller->setRequest($request); + $this->controller->setIdentity(null); + + $result = $this->controller->installEditorAction(); + + $this->assertInstanceOf(JsonModel::class, $result); + $this->assertEquals(401, $this->controller->getResponse()->getStatusCode()); + $this->assertEquals('Unauthorized', $result->getVariables()['message']); + } + + public function testInstallEditorActionRequiresPermission(): void + { + $request = new class { + public function isPost(): bool { return true; } + public function getPost($key = null) { return null; } + public function getHeaders() { + return new class { + public function get($name) { return null; } + }; + } + public function getFiles() { return []; } + }; + + $identity = new class { + public function getId(): int { return 1; } + public function getName(): string { return 'Test User'; } + }; + + $this->controller->setRequest($request); + $this->controller->setIdentity($identity); + $this->controller->setUserAllowed(false); + + $result = $this->controller->installEditorAction(); + + $this->assertInstanceOf(JsonModel::class, $result); + $this->assertEquals(403, $this->controller->getResponse()->getStatusCode()); + $this->assertEquals('Forbidden', $result->getVariables()['message']); + } + + public function testInstallEditorActionWithUploadedZip(): void + { + // Create a valid ZIP with editor contents + $tmpZip = tempnam(sys_get_temp_dir(), 'test-editor-'); + $zip = new \ZipArchive(); + $zip->open($tmpZip, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); + $zip->addFromString('index.html', 'Editor'); + $zip->addEmptyDir('app'); + $zip->close(); + + $request = new class($tmpZip) { + private string $tmpZip; + public function __construct(string $tmpZip) + { + $this->tmpZip = $tmpZip; + } + public function isPost(): bool { return true; } + public function getPost($key = null, $default = null) + { + if ($key === 'version') { + return '4.0.0-test'; + } + return $default; + } + public function getHeaders() { + return new class { + public function get($name) { return null; } + }; + } + public function getFiles() { + return ['file' => [ + 'error' => UPLOAD_ERR_OK, + 'tmp_name' => $this->tmpZip, + ]]; + } + }; + + $identity = new class { + public function getId(): int { return 1; } + public function getName(): string { return 'Admin'; } + }; + + $this->controller->setRequest($request); + $this->controller->setIdentity($identity); + $this->controller->setUserAllowed(true); + + $result = $this->controller->installEditorAction(); + + $this->assertInstanceOf(JsonModel::class, $result); + $vars = $result->getVariables(); + $this->assertTrue($vars['success']); + $this->assertEquals('4.0.0-test', $vars['version']); + $this->assertStringContainsString('4.0.0-test', $vars['message']); + + // Clean up installed files + $editorPath = \ExeLearning\Service\StaticEditorInstaller::getEditorPath(); + if (is_dir($editorPath)) { + $installer = new \ExeLearning\Service\StaticEditorInstaller(); + $installer->cleanupDirectory($editorPath); + } + @unlink($tmpZip); + } + + public function testInstallEditorActionWithInvalidZip(): void + { + $tmpFile = tempnam(sys_get_temp_dir(), 'test-bad-'); + file_put_contents($tmpFile, 'This is not a ZIP'); + + $request = new class($tmpFile) { + private string $tmpFile; + public function __construct(string $tmpFile) + { + $this->tmpFile = $tmpFile; + } + public function isPost(): bool { return true; } + public function getPost($key = null, $default = null) + { + return $default; + } + public function getHeaders() { + return new class { + public function get($name) { return null; } + }; + } + public function getFiles() { + return ['file' => [ + 'error' => UPLOAD_ERR_OK, + 'tmp_name' => $this->tmpFile, + ]]; + } + }; + + $identity = new class { + public function getId(): int { return 1; } + public function getName(): string { return 'Admin'; } + }; + + $this->controller->setRequest($request); + $this->controller->setIdentity($identity); + $this->controller->setUserAllowed(true); + + $result = $this->controller->installEditorAction(); + + $this->assertInstanceOf(JsonModel::class, $result); + $this->assertEquals(500, $this->controller->getResponse()->getStatusCode()); + $this->assertStringContainsString('not a valid ZIP', $result->getVariables()['message']); + + @unlink($tmpFile); + } + + public function testInstallEditorActionWithZipMissingAssetDirs(): void + { + // Upload a valid ZIP but missing required asset directories + $tmpZip = tempnam(sys_get_temp_dir(), 'test-editor-'); + $zip = new \ZipArchive(); + $zip->open($tmpZip, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); + $zip->addFromString('index.html', ''); + $zip->close(); + + $request = new class($tmpZip) { + private string $tmpZip; + public function __construct(string $tmpZip) + { + $this->tmpZip = $tmpZip; + } + public function isPost(): bool { return true; } + public function getPost($key = null, $default = null) { return $default; } + public function getHeaders() { + return new class { + public function get($name) { return null; } + }; + } + public function getFiles() { + return ['file' => [ + 'error' => UPLOAD_ERR_OK, + 'tmp_name' => $this->tmpZip, + ]]; + } + }; + + $identity = new class { + public function getId(): int { return 1; } + public function getName(): string { return 'Admin'; } + }; + + $this->controller->setRequest($request); + $this->controller->setIdentity($identity); + $this->controller->setUserAllowed(true); + + $result = $this->controller->installEditorAction(); + + $this->assertInstanceOf(JsonModel::class, $result); + $this->assertEquals(500, $this->controller->getResponse()->getStatusCode()); + $this->assertStringContainsString('missing expected asset directories', $result->getVariables()['message']); + + @unlink($tmpZip); + } + + public function testInstallEditorActionHasMethod(): void + { + $this->assertTrue(method_exists($this->controller, 'installEditorAction')); + } + + public function testInstallEditorActionWithInvalidDownloadUrl(): void + { + $request = new class { + public function isPost(): bool { return true; } + public function getPost($key = null, $default = null) + { + $data = [ + 'download_url' => 'https://invalid.example.com/nonexistent.zip', + 'version' => '1.0.0', + ]; + if ($key === null) return $default; + return $data[$key] ?? $default; + } + public function getHeaders() { + return new class { + public function get($name) { return null; } + }; + } + public function getFiles() { return []; } + }; + + $identity = new class { + public function getId(): int { return 1; } + public function getName(): string { return 'Admin'; } + }; + + $this->controller->setRequest($request); + $this->controller->setIdentity($identity); + $this->controller->setUserAllowed(true); + + $result = $this->controller->installEditorAction(); + + $this->assertInstanceOf(JsonModel::class, $result); + $this->assertEquals(500, $this->controller->getResponse()->getStatusCode()); + $this->assertFalse($result->getVariables()['success']); + } + public function testSetTeacherModeActionWithNoString(): void { $request = new class { @@ -1042,4 +1309,64 @@ public function getName(): string { return 'Test User'; } $this->assertTrue($result->getVariables()['success']); $this->assertFalse($result->getVariables()['teacherModeVisible']); } + + // ========================================================================= + // buildContentUrl() tests + // ========================================================================= + + public function testBuildContentUrlIncludesNonStandardPort(): void + { + $uri = new class extends \Laminas\Uri\Http { + public function getPort(): ?int { return 8080; } + }; + $request = new class($uri) extends \Laminas\Http\Request { + private $customUri; + public function __construct($uri) { $this->customUri = $uri; } + public function getUri(): \Laminas\Uri\Http { return $this->customUri; } + }; + + $this->controller->setRequest($request); + + $url = $this->callProtectedMethod($this->controller, 'buildContentUrl', ['abc123def456789012345678901234567890abcd']); + + $this->assertStringContainsString(':8080', $url); + $this->assertStringContainsString('/exelearning/content/abc123def456789012345678901234567890abcd/index.html', $url); + } + + public function testBuildContentUrlStripsPlaygroundPrefixFromUriPath(): void + { + $uri = new class extends \Laminas\Uri\Http { + public function getPath(): string { return '/omeka-s-playground/playground/abc123/php83/admin/media/3'; } + }; + $request = new class($uri) extends \Laminas\Http\Request { + private $customUri; + public function __construct($uri) { $this->customUri = $uri; } + public function getUri(): \Laminas\Uri\Http { return $this->customUri; } + }; + + $this->controller->setRequest($request); + + $url = $this->callProtectedMethod($this->controller, 'buildContentUrl', ['abc123def456789012345678901234567890abcd']); + + $this->assertStringContainsString('/omeka-s-playground/playground/abc123/php83/exelearning/content/', $url); + $this->assertStringNotContainsString('/admin/', $url); + } + + public function testExtractBasePathWithAdminRoute(): void + { + $basePath = $this->callProtectedMethod($this->controller, 'extractBasePath', ['/playground/uuid/php83/admin/media/3']); + $this->assertSame('/playground/uuid/php83', $basePath); + } + + public function testExtractBasePathWithApiRoute(): void + { + $basePath = $this->callProtectedMethod($this->controller, 'extractBasePath', ['/playground/uuid/php83/api/exelearning/save/1']); + $this->assertSame('/playground/uuid/php83', $basePath); + } + + public function testExtractBasePathWithNoKnownMarker(): void + { + $basePath = $this->callProtectedMethod($this->controller, 'extractBasePath', ['/some/unknown/path']); + $this->assertSame('', $basePath); + } } diff --git a/test/ExeLearningTest/Media/FileRenderer/ExeLearningRendererTest.php b/test/ExeLearningTest/Media/FileRenderer/ExeLearningRendererTest.php index 4747bfb..a6ba32a 100644 --- a/test/ExeLearningTest/Media/FileRenderer/ExeLearningRendererTest.php +++ b/test/ExeLearningTest/Media/FileRenderer/ExeLearningRendererTest.php @@ -23,7 +23,12 @@ class ExeLearningRendererTest extends TestCase protected function setUp(): void { $this->elpService = $this->createMock(ElpFileService::class); - $this->renderer = new ExeLearningRenderer($this->elpService); + $this->renderer = new ExeLearningRenderer($this->elpService, $this->createMockRequest()); + } + + private function createMockRequest(): \Laminas\Http\Request + { + return new \Laminas\Http\Request(); } private function callProtectedMethod(object $object, string $method, array $args = []) @@ -272,7 +277,7 @@ public function testRenderReturnsHtmlForElpxWithoutHash(): void $elpService->method('getMediaHash')->willReturn(null); $elpService->method('hasPreview')->willReturn(false); - $renderer = new ExeLearningRenderer($elpService); + $renderer = new ExeLearningRenderer($elpService, $this->createMockRequest()); $view = new \Laminas\View\Renderer\PhpRenderer(); $media = new MediaRepresentation( @@ -298,7 +303,7 @@ public function testRenderReturnsIframeForValidElpx(): void $elpService->method('getMediaHash')->willReturn($hash); $elpService->method('hasPreview')->willReturn(true); - $renderer = new ExeLearningRenderer($elpService); + $renderer = new ExeLearningRenderer($elpService, $this->createMockRequest()); $view = new \Laminas\View\Renderer\PhpRenderer(); $media = new MediaRepresentation( @@ -329,7 +334,7 @@ public function testRenderIncludesSecuritySandbox(): void $elpService->method('getMediaHash')->willReturn($hash); $elpService->method('hasPreview')->willReturn(true); - $renderer = new ExeLearningRenderer($elpService); + $renderer = new ExeLearningRenderer($elpService, $this->createMockRequest()); $view = new \Laminas\View\Renderer\PhpRenderer(); $media = new MediaRepresentation( @@ -358,7 +363,7 @@ public function testRenderIncludesDownloadButton(): void $elpService->method('getMediaHash')->willReturn($hash); $elpService->method('hasPreview')->willReturn(true); - $renderer = new ExeLearningRenderer($elpService); + $renderer = new ExeLearningRenderer($elpService, $this->createMockRequest()); $view = new \Laminas\View\Renderer\PhpRenderer(); $media = new MediaRepresentation( @@ -386,7 +391,7 @@ public function testRenderIncludesFullscreenButton(): void $elpService->method('getMediaHash')->willReturn($hash); $elpService->method('hasPreview')->willReturn(true); - $renderer = new ExeLearningRenderer($elpService); + $renderer = new ExeLearningRenderer($elpService, $this->createMockRequest()); $view = new \Laminas\View\Renderer\PhpRenderer(); $media = new MediaRepresentation( @@ -416,7 +421,7 @@ public function testRenderHandlesExceptionGracefully(): void $elpService = $this->createMock(ElpFileService::class); $elpService->method('getMediaHash')->willThrowException(new \Exception('Test error')); - $renderer = new ExeLearningRenderer($elpService); + $renderer = new ExeLearningRenderer($elpService, $this->createMockRequest()); $view = new \Laminas\View\Renderer\PhpRenderer(); $media = new MediaRepresentation( @@ -558,7 +563,7 @@ public function testRenderFallbackWhenHasHashButNoPreview(): void $elpService->method('getMediaHash')->willReturn($hash); $elpService->method('hasPreview')->willReturn(false); - $renderer = new ExeLearningRenderer($elpService); + $renderer = new ExeLearningRenderer($elpService, $this->createMockRequest()); $view = new \Laminas\View\Renderer\PhpRenderer(); $media = new MediaRepresentation( @@ -573,4 +578,95 @@ public function testRenderFallbackWhenHasHashButNoPreview(): void // Should render fallback $this->assertStringContainsString('exelearning-fallback', $result); } + + // ========================================================================= + // buildContentUrl() tests + // ========================================================================= + + public function testBuildContentUrlIncludesNonStandardPort(): void + { + $uri = new class extends \Laminas\Uri\Http { + public function getPort(): ?int { return 8080; } + }; + $request = new class($uri) extends \Laminas\Http\Request { + private $customUri; + public function __construct($uri) { $this->customUri = $uri; } + public function getUri(): \Laminas\Uri\Http { return $this->customUri; } + }; + + $renderer = new ExeLearningRenderer($this->elpService, $request); + + $url = $this->callProtectedMethod($renderer, 'buildContentUrl', ['abc123def456789012345678901234567890abcd']); + + $this->assertStringContainsString(':8080', $url); + $this->assertStringContainsString('/exelearning/content/abc123def456789012345678901234567890abcd/index.html', $url); + } + + public function testBuildContentUrlStripsPlaygroundPrefixFromUriPath(): void + { + $uri = new class extends \Laminas\Uri\Http { + public function getPath(): string { return '/omeka-s-playground/playground/abc123/php83/admin/media/3'; } + }; + $request = new class($uri) extends \Laminas\Http\Request { + private $customUri; + public function __construct($uri) { $this->customUri = $uri; } + public function getUri(): \Laminas\Uri\Http { return $this->customUri; } + }; + + $renderer = new ExeLearningRenderer($this->elpService, $request); + $url = $this->callProtectedMethod($renderer, 'buildContentUrl', ['abc123def456789012345678901234567890abcd']); + + $this->assertStringContainsString('/omeka-s-playground/playground/abc123/php83/exelearning/content/', $url); + $this->assertStringNotContainsString('/admin/', $url); + } + + public function testExtractBasePathWithAdminRoute(): void + { + $basePath = $this->callProtectedMethod($this->renderer, 'extractBasePath', ['/playground/uuid/php83/admin/media/3']); + $this->assertSame('/playground/uuid/php83', $basePath); + } + + public function testExtractBasePathWithPublicRoute(): void + { + $basePath = $this->callProtectedMethod($this->renderer, 'extractBasePath', ['/playground/uuid/php83/s/mysite/item/1']); + $this->assertSame('/playground/uuid/php83', $basePath); + } + + public function testExtractBasePathWithNoKnownMarker(): void + { + $basePath = $this->callProtectedMethod($this->renderer, 'extractBasePath', ['/some/unknown/path']); + $this->assertSame('', $basePath); + } + + // ========================================================================= + // isTeacherModeVisible() tests + // ========================================================================= + + public function testIsTeacherModeVisibleReturnsFalseWhenSetToZero(): void + { + $media = new MediaRepresentation( + 'http://example.com/file.elpx', + 'Test', + 'test.elpx', + 1, + ['exelearning_teacher_mode_visible' => '0'] + ); + + $result = $this->callProtectedMethod($this->renderer, 'isTeacherModeVisible', [$media]); + $this->assertFalse($result); + } + + public function testIsTeacherModeVisibleReturnsTrueWhenSetToOne(): void + { + $media = new MediaRepresentation( + 'http://example.com/file.elpx', + 'Test', + 'test.elpx', + 1, + ['exelearning_teacher_mode_visible' => '1'] + ); + + $result = $this->callProtectedMethod($this->renderer, 'isTeacherModeVisible', [$media]); + $this->assertTrue($result); + } } diff --git a/test/ExeLearningTest/Service/StaticEditorInstallerTest.php b/test/ExeLearningTest/Service/StaticEditorInstallerTest.php new file mode 100644 index 0000000..43d43cf --- /dev/null +++ b/test/ExeLearningTest/Service/StaticEditorInstallerTest.php @@ -0,0 +1,366 @@ +installer = new StaticEditorInstaller(); + } + + protected function tearDown(): void + { + foreach ($this->tempDirs as $dir) { + if (is_dir($dir)) { + $this->recursiveDelete($dir); + } + } + } + + // ========================================================================= + // Static detection methods + // ========================================================================= + + public function testGetEditorPathReturnsExpectedPath(): void + { + $path = StaticEditorInstaller::getEditorPath(); + $this->assertStringEndsWith('dist/static', $path); + } + + public function testIsEditorInstalledReturnsFalseWhenMissing(): void + { + // dist/static/index.html won't exist in the test environment. + $this->assertIsBool(StaticEditorInstaller::isEditorInstalled()); + } + + // ========================================================================= + // getAssetUrl tests + // ========================================================================= + + public function testGetAssetUrlBuildsCorrectUrl(): void + { + $url = $this->installer->getAssetUrl('4.0.0-beta2'); + $this->assertEquals( + 'https://github.com/exelearning/exelearning/releases/download/v4.0.0-beta2/exelearning-static-v4.0.0-beta2.zip', + $url + ); + } + + public function testGetAssetUrlSimpleVersion(): void + { + $url = $this->installer->getAssetUrl('4.0.0'); + $this->assertStringContainsString('exelearning-static-v4.0.0.zip', $url); + } + + // ========================================================================= + // validateZip tests + // ========================================================================= + + public function testValidateZipRejectsNonZip(): void + { + $tmp = tempnam(sys_get_temp_dir(), 'test-'); + file_put_contents($tmp, 'This is not a ZIP file.'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('not a valid ZIP'); + try { + $this->installer->validateZip($tmp); + } finally { + @unlink($tmp); + } + } + + public function testValidateZipAcceptsValidZip(): void + { + $tmp = tempnam(sys_get_temp_dir(), 'test-'); + $zip = new \ZipArchive(); + $zip->open($tmp, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); + $zip->addFromString('test.txt', 'hello'); + $zip->close(); + + // Should not throw. + $this->installer->validateZip($tmp); + $this->assertTrue(true); + @unlink($tmp); + } + + // ========================================================================= + // validateEditorContents tests + // ========================================================================= + + public function testValidateEditorContentsMissingIndex(): void + { + $tmpDir = $this->createTempDir(); + mkdir($tmpDir . '/app', 0755, true); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('missing index.html'); + $this->installer->validateEditorContents($tmpDir); + } + + public function testValidateEditorContentsMissingAssets(): void + { + $tmpDir = $this->createTempDir(); + file_put_contents($tmpDir . '/index.html', ''); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('missing expected asset directories'); + $this->installer->validateEditorContents($tmpDir); + } + + public function testValidateEditorContentsValid(): void + { + $tmpDir = $this->createTempDir(); + file_put_contents($tmpDir . '/index.html', ''); + mkdir($tmpDir . '/app', 0755, true); + + // Should not throw. + $this->installer->validateEditorContents($tmpDir); + $this->assertTrue(true); + } + + // ========================================================================= + // normalizeExtraction tests + // ========================================================================= + + public function testNormalizeExtractionRootFiles(): void + { + $tmpDir = $this->createTempDir(); + file_put_contents($tmpDir . '/index.html', ''); + + $result = $this->installer->normalizeExtraction($tmpDir); + $this->assertEquals($tmpDir, $result); + } + + public function testNormalizeExtractionSingleDir(): void + { + $tmpDir = $this->createTempDir(); + mkdir($tmpDir . '/exelearning-static-v4.0.0', 0755, true); + file_put_contents($tmpDir . '/exelearning-static-v4.0.0/index.html', ''); + + $result = $this->installer->normalizeExtraction($tmpDir); + $this->assertStringContainsString('exelearning-static-v4.0.0', $result); + } + + public function testNormalizeExtractionFailsNoIndex(): void + { + $tmpDir = $this->createTempDir(); + mkdir($tmpDir . '/some-dir', 0755, true); + file_put_contents($tmpDir . '/some-dir/readme.txt', 'hello'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Could not find index.html'); + $this->installer->normalizeExtraction($tmpDir); + } + + // ========================================================================= + // extractZip tests + // ========================================================================= + + public function testExtractZipValid(): void + { + $tmp = tempnam(sys_get_temp_dir(), 'test-'); + $zip = new \ZipArchive(); + $zip->open($tmp, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); + $zip->addFromString('index.html', ''); + $zip->close(); + + $result = $this->installer->extractZip($tmp); + $this->tempDirs[] = $result; + + $this->assertDirectoryExists($result); + $this->assertFileExists($result . '/index.html'); + @unlink($tmp); + } + + // ========================================================================= + // Constants tests + // ========================================================================= + + public function testConstantsAreDefined(): void + { + $this->assertNotEmpty(StaticEditorInstaller::GITHUB_API_URL); + $this->assertNotEmpty(StaticEditorInstaller::ASSET_PREFIX); + $this->assertEquals('exelearning_editor_installed_version', StaticEditorInstaller::SETTING_VERSION); + $this->assertEquals('exelearning_editor_installed_at', StaticEditorInstaller::SETTING_INSTALLED_AT); + } + + // ========================================================================= + // safeInstall tests + // ========================================================================= + + public function testSafeInstallMethodExists(): void + { + $this->assertTrue(method_exists($this->installer, 'safeInstall')); + } + + // ========================================================================= + // discoverLatestVersion tests + // ========================================================================= + + public function testDiscoverLatestVersionMethodExists(): void + { + $this->assertTrue(method_exists($this->installer, 'discoverLatestVersion')); + } + + // ========================================================================= + // downloadAsset tests + // ========================================================================= + + public function testDownloadAssetMethodExists(): void + { + $this->assertTrue(method_exists($this->installer, 'downloadAsset')); + } + + // ========================================================================= + // installLatestEditor tests + // ========================================================================= + + public function testInstallLatestEditorMethodExists(): void + { + $this->assertTrue(method_exists($this->installer, 'installLatestEditor')); + } + + // ========================================================================= + // installFromFile tests + // ========================================================================= + + public function testInstallFromFileMethodExists(): void + { + $this->assertTrue(method_exists($this->installer, 'installFromFile')); + } + + public function testInstallFromFileRejectsInvalidZip(): void + { + $tmp = tempnam(sys_get_temp_dir(), 'test-'); + file_put_contents($tmp, 'not a zip'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('not a valid ZIP'); + try { + $this->installer->installFromFile($tmp, '1.0.0'); + } finally { + @unlink($tmp); + } + } + + public function testInstallFromFileRejectsZipMissingIndex(): void + { + $tmp = tempnam(sys_get_temp_dir(), 'test-'); + $zip = new \ZipArchive(); + $zip->open($tmp, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); + $zip->addFromString('readme.txt', 'hello'); + $zip->close(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Could not find index.html'); + try { + $this->installer->installFromFile($tmp, '1.0.0'); + } finally { + @unlink($tmp); + } + } + + public function testInstallFromFileRejectsZipMissingAssets(): void + { + $tmp = tempnam(sys_get_temp_dir(), 'test-'); + $zip = new \ZipArchive(); + $zip->open($tmp, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); + $zip->addFromString('index.html', ''); + $zip->close(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('missing expected asset directories'); + try { + $this->installer->installFromFile($tmp, '1.0.0'); + } finally { + @unlink($tmp); + } + } + + public function testInstallFromFileSucceeds(): void + { + $tmp = tempnam(sys_get_temp_dir(), 'test-'); + $zip = new \ZipArchive(); + $zip->open($tmp, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); + $zip->addFromString('index.html', 'Editor'); + $zip->addEmptyDir('app'); + $zip->close(); + + $result = $this->installer->installFromFile($tmp, '4.0.0-test'); + + $this->assertEquals('4.0.0-test', $result['version']); + $this->assertArrayHasKey('installed_at', $result); + + // Clean up installed files + $editorPath = StaticEditorInstaller::getEditorPath(); + if (is_dir($editorPath)) { + $this->installer->cleanupDirectory($editorPath); + } + @unlink($tmp); + } + + // ========================================================================= + // cleanupDirectory tests + // ========================================================================= + + public function testCleanupDirectoryRemovesDir(): void + { + $tmpDir = $this->createTempDir(); + file_put_contents($tmpDir . '/test.txt', 'hello'); + mkdir($tmpDir . '/sub', 0755); + file_put_contents($tmpDir . '/sub/nested.txt', 'world'); + + $this->installer->cleanupDirectory($tmpDir); + + $this->assertDirectoryDoesNotExist($tmpDir); + } + + public function testCleanupDirectoryNoopForMissing(): void + { + // Should not throw for non-existent dir + $this->installer->cleanupDirectory('/tmp/nonexistent-' . bin2hex(random_bytes(8))); + $this->assertTrue(true); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + private function createTempDir(): string + { + $tmpDir = sys_get_temp_dir() . '/exelearning-test-' . bin2hex(random_bytes(4)); + mkdir($tmpDir, 0755, true); + $this->tempDirs[] = $tmpDir; + return $tmpDir; + } + + private function recursiveDelete(string $dir): void + { + if (!is_dir($dir)) { + return; + } + $files = array_diff(scandir($dir), ['.', '..']); + foreach ($files as $file) { + $path = $dir . '/' . $file; + is_dir($path) ? $this->recursiveDelete($path) : @unlink($path); + } + @rmdir($dir); + } +} diff --git a/test/Stubs/Laminas/Http/Request.php b/test/Stubs/Laminas/Http/Request.php new file mode 100644 index 0000000..35c6b64 --- /dev/null +++ b/test/Stubs/Laminas/Http/Request.php @@ -0,0 +1,31 @@ +uri = new HttpUri(); + } + + public function getUri(): HttpUri + { + return $this->uri; + } + + public function getBasePath(): string + { + return $this->basePath; + } +} diff --git a/test/Stubs/Laminas/Mvc/Controller/AbstractActionController.php b/test/Stubs/Laminas/Mvc/Controller/AbstractActionController.php index 448fad2..b5a6cd9 100644 --- a/test/Stubs/Laminas/Mvc/Controller/AbstractActionController.php +++ b/test/Stubs/Laminas/Mvc/Controller/AbstractActionController.php @@ -4,6 +4,7 @@ namespace Laminas\Mvc\Controller; +use Laminas\Http\Request; use Laminas\Http\Response; /** @@ -27,8 +28,11 @@ public function setRequest(object $request): void $this->request = $request; } - public function getRequest(): ?object + public function getRequest(): object { + if (!$this->request) { + $this->request = new Request(); + } return $this->request; } @@ -140,6 +144,19 @@ public function userIsAllowed(string $resource, string $privilege): bool } }; } + if ($name === 'Omeka\Settings') { + return new class { + private array $store = []; + public function set(string $key, $value): void + { + $this->store[$key] = $value; + } + public function get(string $key, $default = null) + { + return $this->store[$key] ?? $default; + } + }; + } return null; } }; @@ -230,6 +247,7 @@ public function messenger() return new class { public function addError(string $message): void {} public function addSuccess(string $message): void {} + public function addWarning(string $message): void {} }; } diff --git a/test/Stubs/Laminas/Uri/Http.php b/test/Stubs/Laminas/Uri/Http.php new file mode 100644 index 0000000..0eca006 --- /dev/null +++ b/test/Stubs/Laminas/Uri/Http.php @@ -0,0 +1,36 @@ +scheme; + } + + public function getHost(): string + { + return $this->host; + } + + public function getPort(): ?int + { + return $this->port; + } + + public function getPath(): string + { + return $this->path; + } +} diff --git a/view/exelearning/admin/media-show.phtml b/view/exelearning/admin/media-show.phtml index 96bd26f..0ed9cdd 100644 --- a/view/exelearning/admin/media-show.phtml +++ b/view/exelearning/admin/media-show.phtml @@ -4,18 +4,21 @@ * * @var \Laminas\View\Renderer\PhpRenderer $this * @var \Omeka\Api\Representation\MediaRepresentation $media - * @var string $previewUrl + * @var string $contentPath Relative path: /exelearning/content/{hash}/index.html */ -// Check if user can edit this media +// Check if user can edit this media and if the editor is installed $canEdit = $this->identity() && $this->userIsAllowed('Omeka\Entity\Media', 'update'); +$editorInstalled = \ExeLearning\Service\StaticEditorInstaller::isEditorInstalled(); $editUrl = $this->url('admin/exelearning-editor', ['action' => 'edit', 'id' => $media->id()]); $mediaId = $media->id(); -// Load modal assets -$this->headLink()->appendStylesheet($this->assetUrl('css/exelearning-editor.css', 'ExeLearning')); -$this->headScript()->appendFile($this->assetUrl('js/exelearning-editor.js', 'ExeLearning')); +// Load modal assets only when editor is available +if ($canEdit && $editorInstalled) { + $this->headLink()->appendStylesheet($this->assetUrl('css/exelearning-editor.css', 'ExeLearning')); + $this->headScript()->appendFile($this->assetUrl('js/exelearning-editor.js', 'ExeLearning')); +} ?>