From 47aa033e289679ca280cc9c87a77f751e5cacb3d Mon Sep 17 00:00:00 2001 From: erseco Date: Thu, 23 Apr 2026 20:01:15 +0100 Subject: [PATCH 1/8] feat(styles): administrator-managed style registry for the embedded editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a **Module settings → eXeLearning → Styles** admin page where site admins upload eXeLearning style .zip packages, enable/disable built-in styles, and enable/disable/delete uploaded ones — without rebuilding the static editor bundle or editing the module source tree. Architecture - `ExeLearning\Service\StylesService` owns the pure logic: ZIP validator (path traversal, absolute paths, size cap, extension allow-list, single config.xml), slug allocation with collision suffix, registry persistence in Omeka settings, and the `buildThemeRegistryOverride()` payload the editor consumes. - `ExeLearning\Controller\Admin\StylesController` renders the admin page and handles upload/toggle/delete via POST + CSRF. - `ExeLearning\Controller\StylesServeController` serves extracted style assets via the `/exelearning/styles/:slug/:file` public route with traversal + registry gating. - `EditorController` injects `window.eXeLearning.config.themeRegistryOverride` and mirrors `blockImportInstall` onto the pre-existing `userStyles` (ONLINE_THEMES_INSTALL) flag so the 'Install this project style?' modal is suppressed end-to-end. - `Module.php::getConfigForm` adds a "Styles" link fieldset so admins can jump from the module config page to the management UI. Storage Uploaded style bundles extract to `{OMEKA_PATH}/files/exelearning-styles/{slug}/` — a sibling of the ELP extraction directory, so reinstalling the embedded editor never destroys admin-managed styles. Admin toggle 'Block user-imported styles' defaults to **off** (imports allowed), matching the upstream ONLINE_THEMES_INSTALL=true default. Admins opt in to the lockdown from the Styles page. Tests `test/ExeLearningTest/Service/StylesServiceTest.php` covers the validator, registry shape, install, unique-slug on collision, delete, override payload, the import-blocked contract and the double-nested / flat bundle.json shapes. Minor `.gitignore`: anchor `exelearning/` to the repo root so it does not also ignore `view/exelearning/admin/styles/`. Depends on - exelearning/exelearning#1722 — runtime `themeRegistryOverride` hook (merged). - exelearning/exelearning#1724 — hides the 'Imported' tab when blocked. --- .gitignore | 5 +- Module.php | 31 +- config/module.config.php | 72 ++ src/Controller/Admin/StylesController.php | 184 +++++ .../Admin/StylesControllerFactory.php | 16 + src/Controller/EditorController.php | 25 +- src/Controller/EditorControllerFactory.php | 4 +- src/Controller/StylesServeController.php | 104 +++ .../StylesServeControllerFactory.php | 16 + src/Form/StylesUploadForm.php | 57 ++ src/Service/StylesService.php | 658 ++++++++++++++++++ src/Service/StylesServiceFactory.php | 50 ++ .../Service/StylesServiceTest.php | 233 +++++++ test/Stubs/Omeka/Settings/Settings.php | 26 + view/exelearning/admin/styles/index.phtml | 146 ++++ view/exelearning/editor-bootstrap.phtml | 25 + 16 files changed, 1646 insertions(+), 6 deletions(-) create mode 100644 src/Controller/Admin/StylesController.php create mode 100644 src/Controller/Admin/StylesControllerFactory.php create mode 100644 src/Controller/StylesServeController.php create mode 100644 src/Controller/StylesServeControllerFactory.php create mode 100644 src/Form/StylesUploadForm.php create mode 100644 src/Service/StylesService.php create mode 100644 src/Service/StylesServiceFactory.php create mode 100644 test/ExeLearningTest/Service/StylesServiceTest.php create mode 100644 test/Stubs/Omeka/Settings/Settings.php create mode 100644 view/exelearning/admin/styles/index.phtml diff --git a/.gitignore b/.gitignore index 299762e..314c779 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,8 @@ themes/ dist/ asset/static -# Local editor checkout fetched during build -exelearning/ +# Local editor checkout fetched during build (anchored to repo root so it +# does not also ignore view/exelearning/ or similar subtrees). +/exelearning/ .env .playwright-mcp/ diff --git a/Module.php b/Module.php index 0b820bc..1fee147 100644 --- a/Module.php +++ b/Module.php @@ -706,7 +706,36 @@ public function getConfigForm(PhpRenderer $renderer) $formHtml = $renderer->formCollection($form, false); - return $this->renderEditorStatusSection($renderer, $settings) . $formHtml; + return $this->renderEditorStatusSection($renderer, $settings) + . $this->renderStylesSection($renderer) + . $formHtml; + } + + /** + * Render a short section pointing to the dedicated Styles admin page. + */ + protected function renderStylesSection(PhpRenderer $renderer): string + { + $translate = function ($text) use ($renderer) { + return $renderer->translate($text); + }; + $stylesUrl = $renderer->url('admin/exelearning-styles'); + $html = ''; + return $html; } /** diff --git a/config/module.config.php b/config/module.config.php index 2e271bc..b20a34f 100644 --- a/config/module.config.php +++ b/config/module.config.php @@ -22,23 +22,29 @@ Controller\ApiController::class => Controller\ApiControllerFactory::class, Controller\EditorController::class => Controller\EditorControllerFactory::class, Controller\ContentController::class => Controller\ContentControllerFactory::class, + Controller\StylesServeController::class => Controller\StylesServeControllerFactory::class, + Controller\Admin\StylesController::class => Controller\Admin\StylesControllerFactory::class, ], 'aliases' => [ 'ExeLearning\Controller\Editor' => Controller\EditorController::class, 'ExeLearning\Controller\Api' => Controller\ApiController::class, 'ExeLearning\Controller\Content' => Controller\ContentController::class, + 'ExeLearning\Controller\StylesServe' => Controller\StylesServeController::class, + 'ExeLearning\Controller\Admin\Styles' => Controller\Admin\StylesController::class, ], ], 'service_manager' => [ 'factories' => [ Service\ElpFileService::class => Service\ElpFileServiceFactory::class, + Service\StylesService::class => Service\StylesServiceFactory::class, ], ], 'form_elements' => [ 'invokables' => [ Form\ConfigForm::class => Form\ConfigForm::class, + Form\StylesUploadForm::class => Form\StylesUploadForm::class, ], ], @@ -74,6 +80,19 @@ ], ], ], + 'exelearning-styles-serve' => [ + 'type' => Regex::class, + 'options' => [ + 'regex' => '/exelearning/styles/(?[a-z0-9-]+)(?:/(?.*))?', + 'spec' => '/exelearning/styles/%slug%/%file%', + 'defaults' => [ + '__NAMESPACE__' => 'ExeLearning\Controller', + 'controller' => 'StylesServe', + 'action' => 'serve', + 'file' => 'style.css', + ], + ], + ], 'exelearning-api' => [ 'type' => Literal::class, 'options' => [ @@ -180,11 +199,64 @@ ], ], ], + 'exelearning-styles' => [ + 'type' => Literal::class, + 'options' => [ + 'route' => '/exelearning/styles', + 'defaults' => [ + '__NAMESPACE__' => 'ExeLearning\Controller\Admin', + 'controller' => 'Styles', + 'action' => 'index', + ], + ], + 'may_terminate' => true, + 'child_routes' => [ + 'toggle-builtin' => [ + 'type' => Literal::class, + 'options' => [ + 'route' => '/toggle-builtin', + 'defaults' => ['action' => 'toggleBuiltin'], + ], + ], + 'toggle-uploaded' => [ + 'type' => Literal::class, + 'options' => [ + 'route' => '/toggle-uploaded', + 'defaults' => ['action' => 'toggleUploaded'], + ], + ], + 'delete' => [ + 'type' => Literal::class, + 'options' => [ + 'route' => '/delete', + 'defaults' => ['action' => 'delete'], + ], + ], + 'toggle-block-import' => [ + 'type' => Literal::class, + 'options' => [ + 'route' => '/toggle-block-import', + 'defaults' => ['action' => 'toggleBlockImport'], + ], + ], + ], + ], ], ], ], ], + 'navigation' => [ + 'AdminModule' => [ + [ + 'label' => 'eXeLearning styles', // @translate + 'route' => 'admin/exelearning-styles', + 'resource' => 'ExeLearning\Controller\Admin\Styles', + 'privilege' => 'index', + ], + ], + ], + 'translator' => [ 'translation_file_patterns' => [ [ diff --git a/src/Controller/Admin/StylesController.php b/src/Controller/Admin/StylesController.php new file mode 100644 index 0000000..ff323b1 --- /dev/null +++ b/src/Controller/Admin/StylesController.php @@ -0,0 +1,184 @@ +styles = $styles; + } + + /** + * Render the styles management page. + */ + public function indexAction() + { + if (!$this->allowed()) { + return $this->redirect()->toRoute('admin'); + } + + $form = new StylesUploadForm('styles_upload'); + $form->init(); + + if ($this->getRequest()->isPost()) { + $post = $this->params()->fromPost(); + $files = $this->params()->fromFiles(); + $form->setData(array_merge($post, $files)); + if ($form->isValid()) { + $summary = $this->processUploads($files['styles_zip'] ?? null); + foreach ($summary['installed'] as $title) { + $this->messenger()->addSuccess(sprintf('Style "%s" installed.', $title)); + } + foreach ($summary['errors'] as $error) { + $this->messenger()->addError($error); + } + return $this->redirect()->toRoute('admin/exelearning-styles'); + } + $this->messenger()->addError('Please select at least one ZIP file.'); + } + + $view = new ViewModel([ + 'form' => $form, + 'builtins' => $this->styles->listBuiltinThemes(), + 'uploaded' => $this->styles->listUploadedStyles(), + 'registry' => $this->styles->getRegistry(), + 'blockImport' => $this->styles->isImportBlocked(), + 'maxZipSize' => $this->styles->getMaxZipSize(), + ]); + $view->setTemplate('exelearning/admin/styles/index'); + return $view; + } + + /** + * Toggle an uploaded style's enabled flag. + */ + public function toggleUploadedAction() + { + if (!$this->allowed() || !$this->getRequest()->isPost()) { + return $this->redirect()->toRoute('admin/exelearning-styles'); + } + $slug = (string) $this->params()->fromPost('slug', ''); + $enabled = (bool) $this->params()->fromPost('enabled', 0); + if ($slug !== '') { + $this->styles->setUploadedEnabled($slug, $enabled); + } + return $this->redirect()->toRoute('admin/exelearning-styles'); + } + + /** + * Toggle a built-in style's enabled flag. + */ + public function toggleBuiltinAction() + { + if (!$this->allowed() || !$this->getRequest()->isPost()) { + return $this->redirect()->toRoute('admin/exelearning-styles'); + } + $id = (string) $this->params()->fromPost('id', ''); + $enabled = (bool) $this->params()->fromPost('enabled', 0); + if ($id !== '') { + $this->styles->setBuiltinEnabled($id, $enabled); + } + return $this->redirect()->toRoute('admin/exelearning-styles'); + } + + /** + * Delete an uploaded style. + */ + public function deleteAction() + { + if (!$this->allowed() || !$this->getRequest()->isPost()) { + return $this->redirect()->toRoute('admin/exelearning-styles'); + } + $slug = (string) $this->params()->fromPost('slug', ''); + if ($slug !== '' && $this->styles->deleteUploaded($slug)) { + $this->messenger()->addSuccess('Style deleted.'); + } + return $this->redirect()->toRoute('admin/exelearning-styles'); + } + + /** + * Toggle the "block user-imported styles" policy. + */ + public function toggleBlockImportAction() + { + if (!$this->allowed() || !$this->getRequest()->isPost()) { + return $this->redirect()->toRoute('admin/exelearning-styles'); + } + $enabled = (bool) $this->params()->fromPost('enabled', 0); + $this->styles->setImportBlocked($enabled); + return $this->redirect()->toRoute('admin/exelearning-styles'); + } + + /** + * Capability gate: module administrators only. + */ + private function allowed(): bool + { + if (!$this->identity()) { + return false; + } + $acl = $this->getEvent()->getApplication()->getServiceManager()->get('Omeka\Acl'); + return $acl->userIsAllowed('Omeka\Entity\Module', 'update'); + } + + /** + * Walk the uploaded $_FILES field, validate + extract each ZIP. + * + * @param mixed $files Normalized $_FILES['styles_zip'] (single or array). + * @return array{installed: string[], errors: string[]} + */ + private function processUploads($files): array + { + $summary = ['installed' => [], 'errors' => []]; + if (empty($files)) { + return $summary; + } + // Normalize to a list of single-file arrays. + $entries = []; + if (isset($files['tmp_name']) && is_array($files['tmp_name'])) { + $count = count($files['tmp_name']); + for ($i = 0; $i < $count; $i++) { + $entries[] = [ + 'tmp_name' => $files['tmp_name'][$i] ?? '', + 'name' => $files['name'][$i] ?? '', + 'error' => $files['error'][$i] ?? UPLOAD_ERR_NO_FILE, + ]; + } + } else { + $entries[] = [ + 'tmp_name' => $files['tmp_name'] ?? '', + 'name' => $files['name'] ?? '', + 'error' => $files['error'] ?? UPLOAD_ERR_NO_FILE, + ]; + } + foreach ($entries as $entry) { + if ((int) $entry['error'] !== UPLOAD_ERR_OK || $entry['tmp_name'] === '') { + $summary['errors'][] = sprintf('Upload failed: %s', $entry['name'] ?: 'unknown'); + continue; + } + try { + $result = $this->styles->installFromZip($entry['tmp_name'], (string) $entry['name']); + $summary['installed'][] = $result['title'] ?? $result['name']; + } catch (\Throwable $e) { + $summary['errors'][] = sprintf('%s: %s', $entry['name'] ?: 'file', $e->getMessage()); + } + } + return $summary; + } +} diff --git a/src/Controller/Admin/StylesControllerFactory.php b/src/Controller/Admin/StylesControllerFactory.php new file mode 100644 index 0000000..54cf501 --- /dev/null +++ b/src/Controller/Admin/StylesControllerFactory.php @@ -0,0 +1,16 @@ +get(StylesService::class)); + } +} diff --git a/src/Controller/EditorController.php b/src/Controller/EditorController.php index a4e5c58..5b73441 100644 --- a/src/Controller/EditorController.php +++ b/src/Controller/EditorController.php @@ -5,6 +5,7 @@ use ExeLearning\Service\ElpFileService; use ExeLearning\Service\StaticEditorInstaller; +use ExeLearning\Service\StylesService; use Laminas\Mvc\Controller\AbstractActionController; use Laminas\View\Model\ViewModel; @@ -16,12 +17,18 @@ class EditorController extends AbstractActionController /** @var ElpFileService */ protected $elpService; + /** @var StylesService|null */ + protected $stylesService; + /** - * @param ElpFileService $elpService + * @param ElpFileService $elpService + * @param StylesService|null $stylesService Optional — when absent the + * editor bootstrap omits the themeRegistryOverride payload. */ - public function __construct(ElpFileService $elpService) + public function __construct(ElpFileService $elpService, ?StylesService $stylesService = null) { $this->elpService = $elpService; + $this->stylesService = $stylesService; } /** @@ -109,10 +116,24 @@ public function editAction() ], ]; + // Build the approved style registry the editor will consume via + // window.eXeLearning.config.themeRegistryOverride (see core PR + // exelearning/exelearning#1722). Absolute URLs so the editor + // can fetch style assets from the public serve route. + $themeRegistryOverride = $this->stylesService + ? $this->stylesService->buildThemeRegistryOverride($serverUrl . $basePath) + : [ + 'disabledBuiltins' => [], + 'uploaded' => [], + 'blockImportInstall' => false, + 'fallbackTheme' => 'base', + ]; + $view = new ViewModel([ 'media' => $media, 'config' => $config, 'editorBaseUrl' => $config['editorBaseUrl'], + 'themeRegistryOverride' => $themeRegistryOverride, ]); $view->setTemplate('exelearning/editor-bootstrap'); diff --git a/src/Controller/EditorControllerFactory.php b/src/Controller/EditorControllerFactory.php index 29dc189..2bf1c3e 100644 --- a/src/Controller/EditorControllerFactory.php +++ b/src/Controller/EditorControllerFactory.php @@ -6,12 +6,14 @@ use Interop\Container\ContainerInterface; use Laminas\ServiceManager\Factory\FactoryInterface; use ExeLearning\Service\ElpFileService; +use ExeLearning\Service\StylesService; class EditorControllerFactory implements FactoryInterface { public function __invoke(ContainerInterface $services, $requestedName, array $options = null) { $elpService = $services->get(ElpFileService::class); - return new EditorController($elpService); + $stylesService = $services->get(StylesService::class); + return new EditorController($elpService, $stylesService); } } diff --git a/src/Controller/StylesServeController.php b/src/Controller/StylesServeController.php new file mode 100644 index 0000000..0ac9d9c --- /dev/null +++ b/src/Controller/StylesServeController.php @@ -0,0 +1,104 @@ +styles = $styles; + } + + public function serveAction() + { + $slug = (string) $this->params('slug', ''); + $file = (string) $this->params('file', 'style.css'); + $file = ltrim($file, '/'); + if ($slug === '' || strpos($file, '..') !== false || strpos($slug, '..') !== false) { + return $this->notFound(); + } + + $registry = $this->styles->getRegistry(); + if (!isset($registry['uploaded'][StylesService::normalizeSlug($slug)])) { + return $this->notFound(); + } + + $dir = $this->styles->getStyleDir($slug); + $baseReal = realpath($dir); + $targetReal = realpath($dir . '/' . $file); + if ($baseReal === false + || $targetReal === false + || strpos($targetReal, $baseReal . DIRECTORY_SEPARATOR) !== 0) { + return $this->notFound(); + } + if (!is_file($targetReal) || !is_readable($targetReal)) { + return $this->notFound(); + } + + /** @var Response $response */ + $response = $this->getResponse(); + $response->setStatusCode(200); + $headers = $response->getHeaders(); + $headers->addHeaderLine('Content-Type', $this->guessMimeType($targetReal)); + $headers->addHeaderLine('Content-Length', (string) filesize($targetReal)); + $headers->addHeaderLine('Cache-Control', 'public, max-age=3600'); + $headers->addHeaderLine('X-Content-Type-Options', 'nosniff'); + $response->setContent(file_get_contents($targetReal)); + return $response; + } + + private function notFound(): Response + { + $response = $this->getResponse(); + $response->setStatusCode(404); + $response->setContent('Not found'); + return $response; + } + + /** + * Best-effort MIME guess covering the style package's allowed extensions. + */ + private function guessMimeType(string $path): string + { + $map = [ + 'css' => 'text/css', + 'js' => 'application/javascript', + 'map' => 'application/json', + 'json' => 'application/json', + 'xml' => 'application/xml', + 'html' => 'text/html', + 'htm' => 'text/html', + 'md' => 'text/markdown', + 'txt' => 'text/plain', + 'svg' => 'image/svg+xml', + 'png' => 'image/png', + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + 'ico' => 'image/vnd.microsoft.icon', + 'woff' => 'font/woff', + 'woff2' => 'font/woff2', + 'ttf' => 'font/ttf', + 'otf' => 'font/otf', + 'eot' => 'application/vnd.ms-fontobject', + ]; + $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + return $map[$ext] ?? 'application/octet-stream'; + } +} diff --git a/src/Controller/StylesServeControllerFactory.php b/src/Controller/StylesServeControllerFactory.php new file mode 100644 index 0000000..6a0dd4e --- /dev/null +++ b/src/Controller/StylesServeControllerFactory.php @@ -0,0 +1,16 @@ +get(StylesService::class)); + } +} diff --git a/src/Form/StylesUploadForm.php b/src/Form/StylesUploadForm.php new file mode 100644 index 0000000..c59fe86 --- /dev/null +++ b/src/Form/StylesUploadForm.php @@ -0,0 +1,57 @@ + which renders + * consistently across Omeka admin themes without requiring the full + * Omeka Media entity upload pipeline. + */ +class StylesUploadForm extends Form +{ + public function init(): void + { + $this->setAttribute('enctype', 'multipart/form-data'); + $this->setAttribute('method', 'post'); + + $this->add([ + 'name' => 'styles_zip', + 'type' => Element\File::class, + 'options' => [ + 'label' => 'Style ZIP package(s)', // @translate + 'info' => 'Select one or more .zip files containing a valid config.xml.', // @translate + ], + 'attributes' => [ + 'multiple' => 'multiple', + 'accept' => '.zip,application/zip,application/x-zip-compressed', + 'required' => true, + 'id' => 'styles_zip', + ], + ]); + + $this->add([ + 'name' => 'csrf', + 'type' => Element\Csrf::class, + 'options' => [ + 'csrf_options' => [ + 'timeout' => 3600, + ], + ], + ]); + + $this->add([ + 'name' => 'submit', + 'type' => Element\Submit::class, + 'attributes' => [ + 'value' => 'Upload styles', // @translate + 'class' => 'button', + ], + ]); + } +} diff --git a/src/Service/StylesService.php b/src/Service/StylesService.php new file mode 100644 index 0000000..9726351 --- /dev/null +++ b/src/Service/StylesService.php @@ -0,0 +1,658 @@ +settings = $settings; + $this->filesPath = rtrim($filesPath, '/'); + $this->modulePath = rtrim($modulePath, '/'); + $this->logger = $logger; + } + + // ------------------------------------------------------------------ + // Storage helpers + // ------------------------------------------------------------------ + + public function getStorageDir(): string + { + return $this->filesPath . '/' . self::STORAGE_SUBDIR; + } + + public function getStyleDir(string $slug): string + { + return $this->getStorageDir() . '/' . self::normalizeSlug($slug); + } + + /** + * Public URL for an uploaded style, served via the styles serve route. + */ + public function getStyleUrlPath(string $slug): string + { + return '/exelearning/styles/' . rawurlencode(self::normalizeSlug($slug)); + } + + public function getMaxZipSize(): int + { + return self::DEFAULT_MAX_ZIP_SIZE; + } + + // ------------------------------------------------------------------ + // Registry persistence + // ------------------------------------------------------------------ + + /** + * @return array{uploaded: array, disabled_builtins: string[]} + */ + public function getRegistry(): array + { + $raw = $this->settings->get(self::SETTING_REGISTRY, null); + $data = is_string($raw) ? json_decode($raw, true) : null; + if (!is_array($data)) { + $data = []; + } + return [ + 'uploaded' => isset($data['uploaded']) && is_array($data['uploaded']) ? $data['uploaded'] : [], + 'disabled_builtins' => isset($data['disabled_builtins']) && is_array($data['disabled_builtins']) + ? array_values(array_map('strval', $data['disabled_builtins'])) + : [], + ]; + } + + public function saveRegistry(array $registry): void + { + $this->settings->set(self::SETTING_REGISTRY, json_encode($registry)); + } + + public function isImportBlocked(): bool + { + // Default: imports allowed (matches upstream ONLINE_THEMES_INSTALL=true). + // Admins enable the lockdown explicitly from the Styles page. + $value = $this->settings->get(self::SETTING_BLOCK_IMPORT, null); + if ($value === null || $value === '') { + return false; + } + return (bool) $value; + } + + public function setImportBlocked(bool $blocked): void + { + $this->settings->set(self::SETTING_BLOCK_IMPORT, $blocked ? '1' : '0'); + } + + // ------------------------------------------------------------------ + // Public listings + // ------------------------------------------------------------------ + + /** + * @return array> + */ + public function listBuiltinThemes(): array + { + $bundlePath = $this->modulePath . '/dist/static/data/bundle.json'; + if (!is_file($bundlePath) || !is_readable($bundlePath)) { + return []; + } + $json = @file_get_contents($bundlePath); + if ($json === false || $json === '') { + return []; + } + $data = json_decode($json, true); + return $this->extractThemesFromBundle(is_array($data) ? $data : []); + } + + /** + * Walk a decoded bundle.json payload and return a normalized list of + * theme entries. Accepts both the double-nested shape the core build + * emits and a flat-array shape. + * + * @param array $data Decoded bundle. + * @return array> + */ + public function extractThemesFromBundle(array $data): array + { + if (empty($data['themes'])) { + return []; + } + $themes = $data['themes']; + if (is_array($themes) && isset($themes['themes']) && is_array($themes['themes'])) { + $themes = $themes['themes']; + } + if (!is_array($themes)) { + return []; + } + $out = []; + foreach ($themes as $theme) { + if (!is_array($theme) || empty($theme['name'])) { + continue; + } + $out[] = [ + 'id' => (string) $theme['name'], + 'name' => (string) $theme['name'], + 'title' => (string) ($theme['title'] ?? $theme['name']), + 'version' => (string) ($theme['version'] ?? ''), + 'description' => (string) ($theme['description'] ?? ''), + 'author' => (string) ($theme['author'] ?? ''), + ]; + } + return $out; + } + + /** + * @return array> + */ + public function listUploadedStyles(): array + { + $registry = $this->getRegistry(); + $out = []; + foreach ($registry['uploaded'] as $slug => $meta) { + if (!is_array($meta)) { + continue; + } + $meta['id'] = (string) $slug; + $meta['name'] = (string) $slug; + $meta['url'] = $this->getStyleUrlPath((string) $slug); + $meta['path'] = $this->getStyleDir((string) $slug); + $out[] = $meta; + } + return $out; + } + + /** + * Build the payload consumed by the editor's themeRegistryOverride hook. + * + * @param string $baseUrl Prefix (eg `http://host/basepath`) to produce + * absolute URLs the editor can fetch. + */ + public function buildThemeRegistryOverride(string $baseUrl = ''): array + { + $registry = $this->getRegistry(); + $uploaded = []; + $prefix = rtrim($baseUrl, '/'); + foreach ($registry['uploaded'] as $slug => $meta) { + if (!is_array($meta) || empty($meta['enabled'])) { + continue; + } + $cssFiles = isset($meta['css_files']) && is_array($meta['css_files']) + ? array_values(array_map('strval', $meta['css_files'])) + : ['style.css']; + $uploaded[] = [ + 'id' => (string) $slug, + 'name' => (string) $slug, + 'dirName' => (string) $slug, + 'title' => (string) ($meta['title'] ?? $slug), + 'description' => (string) ($meta['description'] ?? ''), + 'version' => (string) ($meta['version'] ?? ''), + 'author' => (string) ($meta['author'] ?? ''), + 'license' => (string) ($meta['license'] ?? ''), + 'type' => 'uploaded', + 'url' => $prefix . $this->getStyleUrlPath((string) $slug), + 'cssFiles' => $cssFiles, + 'downloadable' => '0', + 'valid' => true, + ]; + } + return [ + 'disabledBuiltins' => $registry['disabled_builtins'], + 'uploaded' => $uploaded, + 'blockImportInstall' => $this->isImportBlocked(), + 'fallbackTheme' => 'base', + ]; + } + + // ------------------------------------------------------------------ + // State mutations + // ------------------------------------------------------------------ + + public function setUploadedEnabled(string $slug, bool $enabled): bool + { + $slug = self::normalizeSlug($slug); + $registry = $this->getRegistry(); + if (!isset($registry['uploaded'][$slug])) { + return false; + } + $registry['uploaded'][$slug]['enabled'] = $enabled; + $this->saveRegistry($registry); + return true; + } + + public function setBuiltinEnabled(string $id, bool $enabled): void + { + $id = self::normalizeSlug($id); + $registry = $this->getRegistry(); + $disabled = $registry['disabled_builtins']; + if ($enabled) { + $disabled = array_values(array_filter($disabled, static fn($d) => $d !== $id)); + } elseif (!in_array($id, $disabled, true)) { + $disabled[] = $id; + } + $registry['disabled_builtins'] = $disabled; + $this->saveRegistry($registry); + } + + public function deleteUploaded(string $slug): bool + { + $slug = self::normalizeSlug($slug); + $registry = $this->getRegistry(); + if (!isset($registry['uploaded'][$slug])) { + return false; + } + $dir = $this->getStyleDir($slug); + if (is_dir($dir)) { + self::recursiveDelete($dir); + } + unset($registry['uploaded'][$slug]); + $this->saveRegistry($registry); + return true; + } + + // ------------------------------------------------------------------ + // ZIP install pipeline + // ------------------------------------------------------------------ + + /** + * Install a style from a ZIP file on disk. + * + * @throws \RuntimeException on validation or extraction failure. + */ + public function installFromZip(string $zipPath, string $origName = ''): array + { + $validation = $this->validateZip($zipPath); + $config = $validation['config']; + $prefix = $validation['prefix']; + + $requestedSlug = !empty($config['name']) + ? $config['name'] + : pathinfo($origName, PATHINFO_FILENAME); + $slug = $this->allocateUniqueSlug((string) $requestedSlug); + + $dest = $this->getStyleDir($slug); + if (!is_dir($dest) && !@mkdir($dest, 0755, true) && !is_dir($dest)) { + throw new \RuntimeException('Failed to create style directory.'); + } + + try { + $this->extractZipSafely($zipPath, $dest, $prefix); + } catch (\Throwable $e) { + self::recursiveDelete($dest); + throw $e; + } + + $cssFiles = self::findCssFiles($dest); + if (empty($cssFiles)) { + self::recursiveDelete($dest); + throw new \RuntimeException('The uploaded style does not contain any stylesheet.'); + } + + $entry = [ + 'title' => (string) ($config['title'] ?? $slug), + 'version' => (string) ($config['version'] ?? ''), + 'author' => (string) ($config['author'] ?? ''), + 'license' => (string) ($config['license'] ?? ''), + 'description' => (string) ($config['description'] ?? ''), + 'css_files' => $cssFiles, + 'enabled' => true, + 'installed_at' => gmdate('c'), + 'checksum' => self::hashZip($zipPath), + 'size' => (int) @filesize($zipPath), + ]; + + $registry = $this->getRegistry(); + $registry['uploaded'][$slug] = $entry; + $this->saveRegistry($registry); + + $entry['id'] = $slug; + $entry['name'] = $slug; + + if ($this->logger) { + $this->logger->info(sprintf('[ExeLearning] Installed style "%s"', $slug)); + } + + return $entry; + } + + /** + * Validate an uploaded ZIP. + * + * @return array{config: array, prefix: string} + * @throws \RuntimeException + */ + public function validateZip(string $zipPath): array + { + if (!is_file($zipPath) || !is_readable($zipPath)) { + throw new \RuntimeException('Uploaded file is missing or unreadable.'); + } + $size = filesize($zipPath); + if ($size === false || $size <= 0) { + throw new \RuntimeException('Uploaded file is empty.'); + } + if ($size > $this->getMaxZipSize()) { + throw new \RuntimeException(sprintf( + 'Uploaded style exceeds the maximum allowed size of %d bytes.', + $this->getMaxZipSize() + )); + } + if (!class_exists('\ZipArchive')) { + throw new \RuntimeException('The ZipArchive PHP extension is not available.'); + } + + $zip = new \ZipArchive(); + if ($zip->open($zipPath, \ZipArchive::CHECKCONS) !== true) { + throw new \RuntimeException('The uploaded file is not a readable ZIP archive.'); + } + + $configPath = null; + $prefix = null; + $entries = []; + + for ($i = 0; $i < $zip->numFiles; $i++) { + $stat = $zip->statIndex($i); + if ($stat === false) { + $zip->close(); + throw new \RuntimeException('The ZIP archive contains unreadable entries.'); + } + $name = (string) $stat['name']; + if (self::isUnsafeZipEntry($name)) { + $zip->close(); + throw new \RuntimeException('Rejected unsafe archive entry: ' . $name); + } + $entries[] = $name; + if (basename($name) === 'config.xml') { + if ($configPath !== null) { + $zip->close(); + throw new \RuntimeException('The archive contains more than one config.xml.'); + } + $configPath = $name; + $dir = trim(str_replace('\\', '/', dirname($name)), '/'); + $prefix = ($dir === '' || $dir === '.') ? '' : $dir . '/'; + } + } + + if ($configPath === null) { + $zip->close(); + throw new \RuntimeException('The style package is missing config.xml.'); + } + + foreach ($entries as $entry) { + if ($prefix !== '' && strpos($entry, $prefix) !== 0) { + $zip->close(); + throw new \RuntimeException( + 'The archive must contain a single root folder or place all files at the root.' + ); + } + if (!self::isAllowedFilename($entry)) { + $zip->close(); + throw new \RuntimeException('File type not allowed in style package: ' . $entry); + } + } + + $configXml = $zip->getFromName($configPath); + $zip->close(); + if ($configXml === false) { + throw new \RuntimeException('config.xml could not be read from the archive.'); + } + + return [ + 'config' => self::parseConfigXml($configXml), + 'prefix' => (string) $prefix, + ]; + } + + /** + * Parse config.xml into a sanitized associative array. + * + * @throws \RuntimeException + */ + public static function parseConfigXml(string $source): array + { + $prev = libxml_use_internal_errors(true); + $xml = simplexml_load_string($source, 'SimpleXMLElement', LIBXML_NONET | LIBXML_NOENT); + libxml_clear_errors(); + libxml_use_internal_errors($prev); + if ($xml === false) { + throw new \RuntimeException('config.xml is not valid XML.'); + } + $name = isset($xml->name) ? trim((string) $xml->name) : ''; + if ($name === '') { + throw new \RuntimeException('config.xml must declare a element.'); + } + return [ + 'name' => self::normalizeSlug($name), + 'title' => isset($xml->title) ? (string) $xml->title : $name, + 'version' => isset($xml->version) ? (string) $xml->version : '', + 'author' => isset($xml->author) ? (string) $xml->author : '', + 'license' => isset($xml->license) ? (string) $xml->license : '', + 'description' => isset($xml->description) ? (string) $xml->description : '', + ]; + } + + /** + * @throws \RuntimeException + */ + private function extractZipSafely(string $zipPath, string $dest, string $prefix): void + { + $zip = new \ZipArchive(); + if ($zip->open($zipPath, \ZipArchive::CHECKCONS) !== true) { + throw new \RuntimeException('Failed to reopen ZIP archive.'); + } + $destReal = rtrim(str_replace('\\', '/', $dest), '/'); + for ($i = 0; $i < $zip->numFiles; $i++) { + $stat = $zip->statIndex($i); + if ($stat === false) { + continue; + } + $name = (string) $stat['name']; + if (self::isUnsafeZipEntry($name)) { + $zip->close(); + throw new \RuntimeException('Refused unsafe archive entry during extraction.'); + } + $relative = $name; + if ($prefix !== '') { + if (strpos($name, $prefix) !== 0) { + continue; + } + $relative = substr($name, strlen($prefix)); + if ($relative === '') { + continue; + } + } + $target = $destReal . '/' . ltrim($relative, '/'); + $target = str_replace('\\', '/', $target); + if (strpos($target, $destReal . '/') !== 0 && $target !== $destReal) { + $zip->close(); + throw new \RuntimeException('Refused path traversal during extraction.'); + } + if (substr($name, -1) === '/') { + if (!is_dir($target) && !@mkdir($target, 0755, true) && !is_dir($target)) { + $zip->close(); + throw new \RuntimeException('Failed to create a directory from the archive.'); + } + continue; + } + $parent = dirname($target); + if (!is_dir($parent) && !@mkdir($parent, 0755, true) && !is_dir($parent)) { + $zip->close(); + throw new \RuntimeException('Failed to create a directory from the archive.'); + } + $contents = $zip->getFromIndex($i); + if ($contents === false) { + $zip->close(); + throw new \RuntimeException('Failed to read a file from the archive.'); + } + if (file_put_contents($target, $contents) === false) { + $zip->close(); + throw new \RuntimeException('Failed to write an extracted file.'); + } + } + $zip->close(); + } + + // ------------------------------------------------------------------ + // Static helpers (also exposed for tests) + // ------------------------------------------------------------------ + + public static function isUnsafeZipEntry(string $name): bool + { + if ($name === '') { + return true; + } + if (strpos($name, '\\') !== false) { + return true; + } + if (strpos($name, '/') === 0) { + return true; + } + if (preg_match('#^[a-zA-Z]+://#', $name)) { + return true; + } + if (preg_match('#(^|/)\.\.(/|$)#', $name)) { + return true; + } + return false; + } + + public static function isAllowedFilename(string $name): bool + { + if ($name === '' || substr($name, -1) === '/') { + return true; + } + $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); + if ($ext === '') { + return false; + } + return in_array($ext, self::ALLOWED_EXTENSIONS, true); + } + + public static function normalizeSlug(string $slug): string + { + $slug = strtolower(trim($slug)); + $slug = preg_replace('/[^a-z0-9-]+/', '-', $slug); + $slug = trim((string) $slug, '-'); + return $slug === '' ? 'style' : $slug; + } + + public function allocateUniqueSlug(string $requested): string + { + $base = self::normalizeSlug($requested); + $builtins = array_map( + static fn($t) => strtolower((string) ($t['name'] ?? '')), + $this->listBuiltinThemes() + ); + $registry = $this->getRegistry(); + $existing = array_map('strtolower', array_keys($registry['uploaded'])); + $taken = array_merge($builtins, $existing); + $slug = $base; + $i = 2; + while (in_array(strtolower($slug), $taken, true)) { + $slug = $base . '-' . $i; + $i++; + } + return $slug; + } + + private static function findCssFiles(string $dir): array + { + $out = []; + if (is_file($dir . '/style.css')) { + $out[] = 'style.css'; + } + $glob = glob($dir . '/*.css'); + if (is_array($glob)) { + foreach ($glob as $file) { + $base = basename($file); + if (!in_array($base, $out, true)) { + $out[] = $base; + } + } + } + return $out; + } + + private static function hashZip(string $path): string + { + $h = @hash_file('sha256', $path); + return is_string($h) ? 'sha256:' . $h : ''; + } + + public static function recursiveDelete(string $dir): void + { + if (!file_exists($dir)) { + return; + } + if (is_link($dir) || is_file($dir)) { + @unlink($dir); + return; + } + $items = @scandir($dir); + if ($items === false) { + return; + } + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + self::recursiveDelete($dir . DIRECTORY_SEPARATOR . $item); + } + @rmdir($dir); + } +} diff --git a/src/Service/StylesServiceFactory.php b/src/Service/StylesServiceFactory.php new file mode 100644 index 0000000..5f816be --- /dev/null +++ b/src/Service/StylesServiceFactory.php @@ -0,0 +1,50 @@ +get('Omeka\Settings'); + $logger = $services->get('Omeka\Logger'); + + $filesPath = null; + try { + $config = $services->get('Config'); + $filesPath = $config['file_store']['local']['base_path'] ?? null; + } catch (\Throwable $e) { + // ignore. + } + if (!$filesPath) { + try { + $fileStore = $services->get('Omeka\File\Store'); + if (method_exists($fileStore, 'getLocalPath')) { + $filesPath = dirname($fileStore->getLocalPath('')); + } + } catch (\Throwable $e) { + // ignore. + } + } + if (!$filesPath) { + $filesPath = defined('OMEKA_PATH') ? OMEKA_PATH . '/files' : '/var/www/html/files'; + } + $volumePath = '/var/www/html/volume/files'; + if (is_dir($volumePath)) { + $filesPath = $volumePath; + } + + $modulePath = dirname(__DIR__, 2); + return new StylesService($settings, $filesPath, $modulePath, $logger); + } +} diff --git a/test/ExeLearningTest/Service/StylesServiceTest.php b/test/ExeLearningTest/Service/StylesServiceTest.php new file mode 100644 index 0000000..673ae0e --- /dev/null +++ b/test/ExeLearningTest/Service/StylesServiceTest.php @@ -0,0 +1,233 @@ +tmpRoot = sys_get_temp_dir() . '/exelearning-styles-' . uniqid(); + mkdir($this->tmpRoot . '/files', 0755, true); + mkdir($this->tmpRoot . '/module', 0755, true); + $settings = new Settings(); + $this->svc = new StylesService( + $settings, + $this->tmpRoot . '/files', + $this->tmpRoot . '/module' + ); + } + + protected function tearDown(): void + { + StylesService::recursiveDelete($this->tmpRoot); + parent::tearDown(); + } + + public function testRegistryDefaultsToEmptyShape(): void + { + $registry = $this->svc->getRegistry(); + $this->assertSame([], $registry['uploaded']); + $this->assertSame([], $registry['disabled_builtins']); + } + + public function testImportIsAllowedByDefault(): void + { + $this->assertFalse($this->svc->isImportBlocked()); + $this->svc->setImportBlocked(true); + $this->assertTrue($this->svc->isImportBlocked()); + } + + public function testSetBuiltinEnabledTogglesDisabledList(): void + { + $this->svc->setBuiltinEnabled('zen', false); + $this->assertSame(['zen'], $this->svc->getRegistry()['disabled_builtins']); + $this->svc->setBuiltinEnabled('zen', false); + $this->assertSame(['zen'], $this->svc->getRegistry()['disabled_builtins']); + $this->svc->setBuiltinEnabled('zen', true); + $this->assertSame([], $this->svc->getRegistry()['disabled_builtins']); + } + + public function testValidateZipRejectsMissingConfig(): void + { + $zip = $this->makeZip(['style.css' => '.x{}']); + $this->expectException(\RuntimeException::class); + try { + $this->svc->validateZip($zip); + } finally { + @unlink($zip); + } + } + + public function testValidateZipRejectsTraversalEntry(): void + { + $zip = $this->makeZip([ + 'config.xml' => $this->configXml('acme'), + '../evil.css' => 'pwn', + ]); + $this->expectException(\RuntimeException::class); + try { + $this->svc->validateZip($zip); + } finally { + @unlink($zip); + } + } + + public function testValidateZipRejectsDisallowedExtension(): void + { + $zip = $this->makeZip([ + 'config.xml' => $this->configXml('acme'), + 'evil.php' => 'expectException(\RuntimeException::class); + try { + $this->svc->validateZip($zip); + } finally { + @unlink($zip); + } + } + + public function testValidateAcceptsValidRootPackage(): void + { + $zip = $this->makeZip([ + 'config.xml' => $this->configXml('acme-2026', 'Acme 2026', '1.0.0'), + 'style.css' => 'body{}', + ]); + $result = $this->svc->validateZip($zip); + $this->assertSame('acme-2026', $result['config']['name']); + $this->assertSame('', $result['prefix']); + @unlink($zip); + } + + public function testInstallExtractsAndRegisters(): void + { + $zip = $this->makeZip([ + 'config.xml' => $this->configXml('acme', 'Acme', '1.0.0'), + 'style.css' => 'body{color:red}', + ]); + $entry = $this->svc->installFromZip($zip, 'acme.zip'); + $this->assertSame('acme', $entry['name']); + $this->assertTrue($entry['enabled']); + $this->assertContains('style.css', $entry['css_files']); + + $this->assertFileExists($this->svc->getStorageDir() . '/acme/style.css'); + $this->assertArrayHasKey('acme', $this->svc->getRegistry()['uploaded']); + @unlink($zip); + } + + public function testInstallAllocatesUniqueSlugOnCollision(): void + { + $z1 = $this->makeZip([ + 'config.xml' => $this->configXml('duo'), + 'style.css' => 'a{}', + ]); + $z2 = $this->makeZip([ + 'config.xml' => $this->configXml('duo'), + 'style.css' => 'b{}', + ]); + $a = $this->svc->installFromZip($z1); + $b = $this->svc->installFromZip($z2); + $this->assertSame('duo', $a['name']); + $this->assertSame('duo-2', $b['name']); + @unlink($z1); + @unlink($z2); + } + + public function testDeleteClearsFilesAndRegistry(): void + { + $zip = $this->makeZip([ + 'config.xml' => $this->configXml('bye'), + 'style.css' => 'x{}', + ]); + $this->svc->installFromZip($zip); + $dir = $this->svc->getStorageDir() . '/bye'; + $this->assertDirectoryExists($dir); + + $this->assertTrue($this->svc->deleteUploaded('bye')); + $this->assertDirectoryDoesNotExist($dir); + $this->assertArrayNotHasKey('bye', $this->svc->getRegistry()['uploaded']); + @unlink($zip); + } + + public function testBuildOverrideRespectsEnabledFlagAndImportPolicy(): void + { + $zip = $this->makeZip([ + 'config.xml' => $this->configXml('seen'), + 'style.css' => 'a{}', + ]); + $this->svc->installFromZip($zip); + $this->svc->setBuiltinEnabled('zen', false); + $this->svc->setImportBlocked(true); + + $override = $this->svc->buildThemeRegistryOverride('https://example.test'); + $this->assertSame(['zen'], $override['disabledBuiltins']); + $this->assertTrue($override['blockImportInstall']); + $this->assertSame('base', $override['fallbackTheme']); + $this->assertCount(1, $override['uploaded']); + $this->assertSame('seen', $override['uploaded'][0]['id']); + $this->assertSame( + 'https://example.test/exelearning/styles/seen', + $override['uploaded'][0]['url'] + ); + + $this->svc->setUploadedEnabled('seen', false); + $override = $this->svc->buildThemeRegistryOverride(); + $this->assertCount(0, $override['uploaded']); + @unlink($zip); + } + + public function testExtractThemesFromBundleAcceptsBothShapes(): void + { + $flat = ['themes' => [['name' => 'a']]]; + $nested = ['themes' => ['themes' => [['name' => 'b']]]]; + $this->assertCount(1, $this->svc->extractThemesFromBundle($flat)); + $this->assertCount(1, $this->svc->extractThemesFromBundle($nested)); + $this->assertSame([], $this->svc->extractThemesFromBundle([])); + $this->assertSame([], $this->svc->extractThemesFromBundle(['themes' => 'nope'])); + } + + // ---------- helpers ---------- + + private function makeZip(array $entries): string + { + $path = tempnam(sys_get_temp_dir(), 'omkstyle') . '.zip'; + @unlink($path); + $zip = new ZipArchive(); + if ($zip->open($path, ZipArchive::CREATE) !== true) { + $this->fail('Could not create test zip'); + } + foreach ($entries as $name => $contents) { + $zip->addFromString($name, $contents); + } + $zip->close(); + return $path; + } + + private function configXml(string $name, string $title = '', string $version = '1.0.0'): string + { + $title = $title === '' ? ucfirst($name) : $title; + return '' + . '' + . '' . htmlspecialchars($name) . '' + . '' . htmlspecialchars($title) . '' + . '' . htmlspecialchars($version) . '' + . 'Test' + . 'CC-BY-SA' + . 'Test theme.' + . ''; + } +} diff --git a/test/Stubs/Omeka/Settings/Settings.php b/test/Stubs/Omeka/Settings/Settings.php new file mode 100644 index 0000000..85ff95a --- /dev/null +++ b/test/Stubs/Omeka/Settings/Settings.php @@ -0,0 +1,26 @@ + */ + private array $store = []; + + public function get(string $key, $default = null) + { + return array_key_exists($key, $this->store) ? $this->store[$key] : $default; + } + + public function set(string $key, $value): void + { + $this->store[$key] = $value; + } +} diff --git a/view/exelearning/admin/styles/index.phtml b/view/exelearning/admin/styles/index.phtml new file mode 100644 index 0000000..91a6169 --- /dev/null +++ b/view/exelearning/admin/styles/index.phtml @@ -0,0 +1,146 @@ +plugin('escapeHtml'); +$escapeAttr = $this->plugin('escapeHtmlAttr'); +$translate = $this->plugin('translate'); +$url = $this->plugin('url'); + +$this->htmlElement('body')->appendAttribute('class', 'exelearning-styles'); +$this->headLink()->appendStylesheet($this->assetUrl('css/exelearning-admin.css', 'ExeLearning')); + +$this->headTitle($translate('eXeLearning styles')); + +$toggleBuiltin = $url('admin/exelearning-styles/toggle-builtin'); +$toggleUploaded = $url('admin/exelearning-styles/toggle-uploaded'); +$delete = $url('admin/exelearning-styles/delete'); +$toggleBlockImport = $url('admin/exelearning-styles/toggle-block-import'); +$sizeHint = function_exists('size_format') ? size_format($maxZipSize) : ($maxZipSize . ' bytes'); +?> + +pageTitle($translate('eXeLearning styles'), 1) ?> + +

+ +

+
+ formCollection(new \Laminas\Form\Form(), false) // inline CSRF via Omeka global layout ?> + + +

+ +

+ +
+ +

+prepare(); ?> +form()->openTag($form) ?> + formRow($form->get('styles_zip')) ?> + formRow($form->get('csrf')) ?> + formRow($form->get('submit')) ?> +form()->closeTag() ?> +

+ +

+ +

+ +

+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+
+
+ + +
+
+ + +

+ +

+ + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+
+ diff --git a/view/exelearning/editor-bootstrap.phtml b/view/exelearning/editor-bootstrap.phtml index 3ad85a4..7ba682c 100644 --- a/view/exelearning/editor-bootstrap.phtml +++ b/view/exelearning/editor-bootstrap.phtml @@ -9,6 +9,7 @@ * @var \Omeka\Api\Representation\MediaRepresentation $media * @var array $config * @var string $editorBaseUrl + * @var array $themeRegistryOverride */ // Load editor template from local dist. @@ -24,6 +25,15 @@ if (empty($template)) { // Prepare JSON-safe configuration $configJson = json_encode($config, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); $editorBaseUrlJs = json_encode($editorBaseUrl, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); +$themeOverrideJson = json_encode( + $themeRegistryOverride ?? [ + 'disabledBuiltins' => [], + 'uploaded' => [], + 'blockImportInstall' => false, + 'fallbackTheme' => 'base', + ], + JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP +); // Build the configuration script $configScript = <<