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/data/nginx-exelearning.conf b/data/nginx-exelearning.conf index 4612043..9dde0df 100644 --- a/data/nginx-exelearning.conf +++ b/data/nginx-exelearning.conf @@ -4,6 +4,14 @@ location ^~ /exelearning/content/ { try_files $uri /index.php$is_args$args; } +# Route admin-uploaded style asset requests to the PHP serve controller. +# Without this prefix location, the default static-files regex that +# catches .css/.js/.png in the Omeka nginx vhost short-circuits these +# URLs to the filesystem and 404s before they reach Laminas. +location ^~ /exelearning/styles/ { + try_files $uri /index.php$is_args$args; +} + # SECURITY: Block direct access to extracted eXeLearning files location ^~ /files/exelearning/ { return 403; diff --git a/language/es.mo b/language/es.mo index 7040760..c2e9099 100644 Binary files a/language/es.mo and b/language/es.mo differ diff --git a/language/es.po b/language/es.po index 3d35bc6..2fbf2f5 100644 --- a/language/es.po +++ b/language/es.po @@ -208,3 +208,129 @@ 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." + +#: view/exelearning/admin/styles/index.phtml +msgid "eXeLearning styles" +msgstr "Estilos de eXeLearning" + +#: view/exelearning/admin/styles/index.phtml +msgid "Upload eXeLearning style packages and control which styles the embedded editor exposes. Disabled built-ins are hidden from the editor selector; uploaded styles can be enabled, disabled, or deleted at any time." +msgstr "Sube paquetes de estilos de eXeLearning y controla qué estilos expone el editor integrado. Los estilos integrados deshabilitados quedan ocultos en el selector del editor; los estilos subidos se pueden habilitar, deshabilitar o eliminar en cualquier momento." + +#: view/exelearning/admin/styles/index.phtml +msgid "Import policy" +msgstr "Política de importación" + +#: view/exelearning/admin/styles/index.phtml +#: src/Controller/Admin/StylesController.php +msgid "Block user-imported styles" +msgstr "Bloquear estilos importados por el usuario" + +#: view/exelearning/admin/styles/index.phtml +msgid "When enabled, the embedded editor hides the \"User styles\" tab and silently refuses to install a style bundled inside an imported .elpx project. This mirrors the eXeLearning ONLINE_THEMES_INSTALL=false behavior." +msgstr "Cuando está activado, el editor integrado oculta la pestaña «Estilos del usuario» y rechaza de forma silenciosa instalar un estilo incluido en un proyecto .elpx importado. Equivale al comportamiento de eXeLearning ONLINE_THEMES_INSTALL=false." + +#: view/exelearning/admin/styles/index.phtml +msgid "Currently blocking — click to allow imports" +msgstr "Actualmente bloqueando — pulsa para permitir importaciones" + +#: view/exelearning/admin/styles/index.phtml +msgid "Currently allowing — click to block imports" +msgstr "Actualmente permitiendo — pulsa para bloquear importaciones" + +#: view/exelearning/admin/styles/index.phtml +msgid "Upload a new style" +msgstr "Subir un estilo nuevo" + +#: src/Form/StylesUploadForm.php +msgid "Style ZIP package(s)" +msgstr "Paquete(s) ZIP de estilo" + +#: src/Form/StylesUploadForm.php +msgid "Select one or more .zip files containing a valid config.xml." +msgstr "Selecciona uno o más archivos .zip con un config.xml válido." + +#: src/Form/StylesUploadForm.php +msgid "Upload styles" +msgstr "Subir estilos" + +#: view/exelearning/admin/styles/index.phtml +msgid "Maximum file size: %s. Only .zip packages containing a valid config.xml are accepted." +msgstr "Tamaño máximo: %s. Solo se aceptan paquetes .zip con un config.xml válido." + +#: view/exelearning/admin/styles/index.phtml +msgid "Uploaded styles" +msgstr "Estilos subidos" + +#: view/exelearning/admin/styles/index.phtml +msgid "No uploaded styles yet." +msgstr "Todavía no hay estilos subidos." + +#: view/exelearning/admin/styles/index.phtml +msgid "Built-in styles" +msgstr "Estilos integrados" + +#: view/exelearning/admin/styles/index.phtml +msgid "Built-in styles are not available because the embedded editor is not installed." +msgstr "Los estilos integrados no están disponibles porque el editor integrado no está instalado." + +#: view/exelearning/admin/styles/index.phtml +msgid "Title" +msgstr "Título" + +#: view/exelearning/admin/styles/index.phtml +msgid "Id" +msgstr "Id" + +#: view/exelearning/admin/styles/index.phtml +msgid "Version" +msgstr "Versión" + +#: view/exelearning/admin/styles/index.phtml +msgid "Enabled" +msgstr "Habilitado" + +#: view/exelearning/admin/styles/index.phtml +msgid "Actions" +msgstr "Acciones" + +#: view/exelearning/admin/styles/index.phtml +msgid "Enable" +msgstr "Habilitar" + +#: view/exelearning/admin/styles/index.phtml +msgid "Disable" +msgstr "Deshabilitar" + +#: view/exelearning/admin/styles/index.phtml +msgid "Delete" +msgstr "Eliminar" + +#: view/exelearning/admin/styles/index.phtml +msgid "Delete this style? This cannot be undone." +msgstr "¿Eliminar este estilo? Esta acción no se puede deshacer." + +#: src/Controller/Admin/StylesController.php +msgid "Style \"%s\" installed." +msgstr "Estilo «%s» instalado." + +#: src/Controller/Admin/StylesController.php +msgid "Style deleted." +msgstr "Estilo eliminado." + +#: Module.php +msgid "Styles" +msgstr "Estilos" + +#: Module.php +msgid "Style management" +msgstr "Gestión de estilos" + +#: Module.php +msgid "Open styles page" +msgstr "Abrir página de estilos" + +#: Module.php +msgid "Upload eXeLearning style packages, enable/disable built-in styles, and control the \"Block user-imported styles\" policy from a dedicated page." +msgstr "Sube paquetes de estilos de eXeLearning, habilita o deshabilita los estilos integrados y controla la política «Bloquear estilos importados por el usuario» desde una página dedicada." + diff --git a/src/Controller/Admin/StylesController.php b/src/Controller/Admin/StylesController.php new file mode 100644 index 0000000..da13591 --- /dev/null +++ b/src/Controller/Admin/StylesController.php @@ -0,0 +1,239 @@ +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. + * + * Protected so a test subclass can override it without having to + * wire up an entire Laminas service manager. + */ + protected 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 across the three shapes we can receive for styles_zip: + // a) Raw PHP multi-file: ['name' => [...], 'tmp_name' => [...], ...] + // b) Raw PHP single-file: ['name' => '...', 'tmp_name' => '...', ...] + // c) Laminas-transposed list: [ ['name' => '...', 'tmp_name' => '...'], ... ] + // Only (c) arrives from $this->params()->fromFiles() with a multi + // input, so it MUST be handled or every multi-upload reports "no + // file was selected". + $entries = []; + $isTransposedList = array_is_list($files) + && isset($files[0]) + && is_array($files[0]) + && array_key_exists('tmp_name', $files[0]); + if ($isTransposedList) { + foreach ($files as $item) { + if (!is_array($item)) { + continue; + } + $entries[] = [ + 'tmp_name' => $item['tmp_name'] ?? '', + 'name' => $item['name'] ?? '', + 'error' => $item['error'] ?? UPLOAD_ERR_NO_FILE, + ]; + } + } elseif (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 for %s: %s', + $entry['name'] !== '' ? $entry['name'] : 'unknown file', + self::uploadErrorMessage((int) $entry['error']) + ); + 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; + } + + /** + * Translate a PHP $_FILES error code into an admin-facing string. + */ + private static function uploadErrorMessage(int $code): string + { + switch ($code) { + case UPLOAD_ERR_OK: + return 'OK'; + case UPLOAD_ERR_INI_SIZE: + return 'file exceeds upload_max_filesize'; + case UPLOAD_ERR_FORM_SIZE: + return 'file exceeds the form MAX_FILE_SIZE'; + case UPLOAD_ERR_PARTIAL: + return 'the file was only partially uploaded'; + case UPLOAD_ERR_NO_FILE: + return 'no file was selected'; + case UPLOAD_ERR_NO_TMP_DIR: + return 'PHP is missing a temporary folder'; + case UPLOAD_ERR_CANT_WRITE: + return 'PHP could not write the uploaded file to disk'; + case UPLOAD_ERR_EXTENSION: + return 'a PHP extension stopped the upload'; + default: + return sprintf('unknown upload error (code %d)', $code); + } + } +} 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..7c4d5b9 --- /dev/null +++ b/src/Controller/StylesServeController.php @@ -0,0 +1,115 @@ +styles = $styles; + } + + public function serveAction() + { + return $this->serveStyle( + (string) $this->params('slug', ''), + (string) $this->params('file', 'style.css') + ); + } + + /** + * Resolve a ({slug}, {file}) pair against the registered style dir and + * write the response. Exposed (not private) so tests can exercise the + * routing-independent half of the serve path. + */ + public function serveStyle(string $slug, string $file): Response + { + $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..16d1957 --- /dev/null +++ b/src/Service/StylesService.php @@ -0,0 +1,747 @@ +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']; + $files = $this->listUploadedFiles((string) $slug); + $styleUrl = $prefix . $this->getStyleUrlPath((string) $slug); + $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' => 'admin', + 'url' => $styleUrl, + 'cssFiles' => $cssFiles, + 'files' => $files, + 'icons' => $this->scanUploadedIcons((string) $slug, $styleUrl), + '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); + } + + /** + * Walk an uploaded style's extracted directory and return every file + * inside it as a list of forward-slash relative paths. The embedded + * editor's ResourceFetcher consumes this manifest via + * themeRegistryOverride.uploaded[].files so admin-approved styles can + * be fetched file-by-file instead of expecting a zip bundle under + * /bundles/themes/.zip. + * + * @return string[] + */ + public function listUploadedFiles(string $slug): array + { + $slug = self::normalizeSlug($slug); + $dir = $this->getStorageDir() . '/' . $slug; + if (!is_dir($dir)) { + return []; + } + $baseLen = strlen(rtrim($dir, '/') . '/'); + $out = []; + try { + $iter = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::LEAVES_ONLY + ); + foreach ($iter as $fileInfo) { + if (!$fileInfo->isFile()) { + continue; + } + $absolute = (string) $fileInfo->getPathname(); + $relative = substr($absolute, $baseLen); + $relative = str_replace(DIRECTORY_SEPARATOR, '/', $relative); + $out[] = $relative; + } + } catch (\Exception $e) { + return []; + } + sort($out); + return $out; + } + + /** + * Scan an uploaded style's `icons/` subfolder and return the editor-shape + * icon map. Mirrors the upstream theme-parser.ts::scanThemeIcons logic so + * the iDevice icon picker shows icons shipped with admin-uploaded styles + * (built-in themes get scanned by the editor itself; admin-uploaded ones + * arrive via themeRegistryOverride and need the icon list pre-built). + * + * @return array + */ + public function scanUploadedIcons(string $slug, string $styleUrl): array + { + $slug = self::normalizeSlug($slug); + $dir = $this->getStyleDir($slug) . '/icons'; + if (!is_dir($dir)) { + return []; + } + $entries = scandir($dir); + if ($entries === false) { + return []; + } + $out = []; + foreach ($entries as $name) { + if ($name === '.' || $name === '..') { + continue; + } + $path = $dir . '/' . $name; + if (!is_file($path)) { + continue; + } + $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); + if (!in_array($ext, ['png', 'svg', 'gif', 'jpg', 'jpeg'], true)) { + continue; + } + $iconId = pathinfo($name, PATHINFO_FILENAME); + $out[$iconId] = [ + 'id' => $iconId, + 'title' => $iconId, + 'type' => 'img', + 'value' => rtrim($styleUrl, '/') . '/icons/' . rawurlencode($name), + ]; + } + ksort($out); + return $out; + } + + 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/Controller/Admin/StylesControllerTest.php b/test/ExeLearningTest/Controller/Admin/StylesControllerTest.php new file mode 100644 index 0000000..a9467e6 --- /dev/null +++ b/test/ExeLearningTest/Controller/Admin/StylesControllerTest.php @@ -0,0 +1,268 @@ +tmpRoot = sys_get_temp_dir() . '/exelearning-adm-' . uniqid(); + mkdir($this->tmpRoot . '/files', 0755, true); + mkdir($this->tmpRoot . '/module', 0755, true); + $this->svc = new StylesService(new Settings(), $this->tmpRoot . '/files', $this->tmpRoot . '/module'); + $this->controller = new StylesController($this->svc); + } + + protected function tearDown(): void + { + StylesService::recursiveDelete($this->tmpRoot); + parent::tearDown(); + } + + public function testProcessUploadsReturnsEmptySummaryWithNoFiles(): void + { + $summary = $this->invokePrivate('processUploads', [null]); + $this->assertSame([], $summary['installed']); + $this->assertSame([], $summary['errors']); + } + + public function testProcessUploadsNormalizesSingleFileUpload(): void + { + $zip = $this->makeZip('acme', 'body{}'); + $summary = $this->invokePrivate('processUploads', [[ + 'tmp_name' => $zip, + 'name' => 'acme.zip', + 'error' => UPLOAD_ERR_OK, + ]]); + $this->assertSame(['Acme'], $summary['installed']); + $this->assertSame([], $summary['errors']); + @unlink($zip); + } + + public function testProcessUploadsNormalizesMultiFileUpload(): void + { + $zip1 = $this->makeZip('alpha', 'a{}'); + $zip2 = $this->makeZip('beta', 'b{}'); + $summary = $this->invokePrivate('processUploads', [[ + 'tmp_name' => [$zip1, $zip2], + 'name' => ['alpha.zip', 'beta.zip'], + 'error' => [UPLOAD_ERR_OK, UPLOAD_ERR_OK], + ]]); + $this->assertCount(2, $summary['installed']); + $this->assertEmpty($summary['errors']); + @unlink($zip1); + @unlink($zip2); + } + + public function testProcessUploadsRecordsErrorsForBrokenUploads(): void + { + $summary = $this->invokePrivate('processUploads', [[ + 'tmp_name' => ['', ''], + 'name' => ['a.zip', 'b.zip'], + 'error' => [UPLOAD_ERR_NO_FILE, UPLOAD_ERR_PARTIAL], + ]]); + $this->assertEmpty($summary['installed']); + $this->assertCount(2, $summary['errors']); + foreach ($summary['errors'] as $msg) { + $this->assertStringStartsWith('Upload failed for', $msg); + } + } + + public function testProcessUploadsHandlesTransposedListShape(): void + { + $zip1 = $this->makeZip('alpha', 'a{}'); + $zip2 = $this->makeZip('beta', 'b{}'); + $summary = $this->invokePrivate('processUploads', [[ + ['tmp_name' => $zip1, 'name' => 'alpha.zip', 'error' => UPLOAD_ERR_OK], + ['tmp_name' => $zip2, 'name' => 'beta.zip', 'error' => UPLOAD_ERR_OK], + ]]); + $this->assertCount(2, $summary['installed']); + $this->assertEmpty($summary['errors']); + @unlink($zip1); + @unlink($zip2); + } + + public function testUploadErrorMessageCoversEveryPhpErrCode(): void + { + $reflection = new \ReflectionMethod( + \ExeLearning\Controller\Admin\StylesController::class, + 'uploadErrorMessage' + ); + $reflection->setAccessible(true); + foreach ([ + UPLOAD_ERR_OK, + UPLOAD_ERR_INI_SIZE, + UPLOAD_ERR_FORM_SIZE, + UPLOAD_ERR_PARTIAL, + UPLOAD_ERR_NO_FILE, + UPLOAD_ERR_NO_TMP_DIR, + UPLOAD_ERR_CANT_WRITE, + UPLOAD_ERR_EXTENSION, + 999, // unknown code + ] as $code) { + $msg = $reflection->invoke(null, $code); + $this->assertIsString($msg); + $this->assertNotEmpty($msg); + } + } + + public function testProcessUploadsCapturesServiceFailures(): void + { + // Invalid ZIP (no config.xml). + $zip = tempnam(sys_get_temp_dir(), 'bad') . '.zip'; + @unlink($zip); + $z = new \ZipArchive(); + $z->open($zip, \ZipArchive::CREATE); + $z->addFromString('style.css', '.x{}'); + $z->close(); + + $summary = $this->invokePrivate('processUploads', [[ + 'tmp_name' => $zip, + 'name' => 'bad.zip', + 'error' => UPLOAD_ERR_OK, + ]]); + $this->assertEmpty($summary['installed']); + $this->assertCount(1, $summary['errors']); + $this->assertStringContainsString('bad.zip', $summary['errors'][0]); + @unlink($zip); + } + + // ------------------------------------------------------------------ + // Action-level tests via a controller subclass that overrides the + // Laminas MVC plugin helpers. We keep them deliberately small — the + // real work happens in the service, which is exercised elsewhere. + // ------------------------------------------------------------------ + + public function testIndexActionDeniesWhenUserIsNotAllowed(): void + { + $ctrl = new TestableStylesController($this->svc, false); + $result = $ctrl->indexAction(); + $this->assertSame(['redirect' => 'admin'], $ctrl->lastRedirect); + } + + public function testIndexActionRendersViewWhenAllowed(): void + { + $ctrl = new TestableStylesController($this->svc, true); + $ctrl->stubRequestIsPost = false; + $result = $ctrl->indexAction(); + $this->assertInstanceOf(\Laminas\View\Model\ViewModel::class, $result); + $vars = $result->getVariables(); + $this->assertArrayHasKey('form', $vars); + $this->assertArrayHasKey('builtins', $vars); + $this->assertArrayHasKey('uploaded', $vars); + $this->assertSame('exelearning/admin/styles/index', $result->getTemplate()); + } + + public function testToggleUploadedActionRoutesThroughService(): void + { + $zip = $this->makeZip('acme', 'body{}'); + $this->svc->installFromZip($zip, 'acme.zip'); + $this->assertTrue($this->svc->getRegistry()['uploaded']['acme']['enabled']); + + $ctrl = new TestableStylesController($this->svc, true); + $ctrl->stubRequestIsPost = true; + $ctrl->stubPost = ['slug' => 'acme', 'enabled' => 0]; + $ctrl->toggleUploadedAction(); + + $this->assertFalse($this->svc->getRegistry()['uploaded']['acme']['enabled']); + $this->assertSame(['redirect' => 'admin/exelearning-styles'], $ctrl->lastRedirect); + @unlink($zip); + } + + public function testToggleUploadedActionShortCircuitsOnGet(): void + { + $ctrl = new TestableStylesController($this->svc, true); + $ctrl->stubRequestIsPost = false; + $ctrl->toggleUploadedAction(); + $this->assertSame(['redirect' => 'admin/exelearning-styles'], $ctrl->lastRedirect); + } + + public function testToggleBuiltinActionPropagatesToService(): void + { + $ctrl = new TestableStylesController($this->svc, true); + $ctrl->stubRequestIsPost = true; + $ctrl->stubPost = ['id' => 'zen', 'enabled' => 0]; + $ctrl->toggleBuiltinAction(); + $this->assertSame(['zen'], $this->svc->getRegistry()['disabled_builtins']); + } + + public function testDeleteActionRemovesUploadedAndFlashes(): void + { + $zip = $this->makeZip('goodbye', 'x{}'); + $this->svc->installFromZip($zip, 'goodbye.zip'); + $this->assertArrayHasKey('goodbye', $this->svc->getRegistry()['uploaded']); + + $ctrl = new TestableStylesController($this->svc, true); + $ctrl->stubRequestIsPost = true; + $ctrl->stubPost = ['slug' => 'goodbye']; + $ctrl->deleteAction(); + + $this->assertArrayNotHasKey('goodbye', $this->svc->getRegistry()['uploaded']); + $this->assertContains('Style deleted.', $ctrl->messengerSuccess); + @unlink($zip); + } + + public function testDeleteActionDeniedWhenNotAllowed(): void + { + $ctrl = new TestableStylesController($this->svc, false); + $ctrl->stubRequestIsPost = true; + $ctrl->stubPost = ['slug' => 'whatever']; + $ctrl->deleteAction(); + $this->assertSame(['redirect' => 'admin/exelearning-styles'], $ctrl->lastRedirect); + } + + public function testToggleBlockImportActionPropagates(): void + { + $ctrl = new TestableStylesController($this->svc, true); + $ctrl->stubRequestIsPost = true; + $ctrl->stubPost = ['enabled' => 1]; + $ctrl->toggleBlockImportAction(); + $this->assertTrue($this->svc->isImportBlocked()); + + $ctrl->stubPost = ['enabled' => 0]; + $ctrl->toggleBlockImportAction(); + $this->assertFalse($this->svc->isImportBlocked()); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private function invokePrivate(string $name, array $args) + { + $ref = new ReflectionClass($this->controller); + $method = $ref->getMethod($name); + $method->setAccessible(true); + return $method->invokeArgs($this->controller, $args); + } + + private function makeZip(string $slug, string $cssBody = 'body{}'): string + { + $path = tempnam(sys_get_temp_dir(), 'admstyle') . '.zip'; + @unlink($path); + $zip = new \ZipArchive(); + $zip->open($path, \ZipArchive::CREATE); + $zip->addFromString('config.xml', + '' . $slug . '' + . '' . ucfirst($slug) . '1.0' + ); + $zip->addFromString('style.css', $cssBody); + $zip->close(); + return $path; + } +} diff --git a/test/ExeLearningTest/Controller/Admin/TestableStylesController.php b/test/ExeLearningTest/Controller/Admin/TestableStylesController.php new file mode 100644 index 0000000..2711d7d --- /dev/null +++ b/test/ExeLearningTest/Controller/Admin/TestableStylesController.php @@ -0,0 +1,115 @@ +allowedFlag = $allowed; + } + + // Override the private/protected MVC interactions. + public function params($name = null, $default = null) + { + if (is_string($name)) { + return $this->stubPost[$name] ?? $default; + } + // Mimic the plugin helper returning a params-like object. + $post = $this->stubPost; + return new class($post) { + private array $post; + public function __construct(array $post) + { + $this->post = $post; + } + public function fromPost($name = null, $default = null) + { + if ($name === null) { + return $this->post; + } + return $this->post[$name] ?? $default; + } + public function fromFiles($name = null, $default = null) + { + return $default; + } + }; + } + + public function getRequest(): object + { + return new class($this->stubRequestIsPost) { + private bool $post; + public function __construct(bool $post) + { + $this->post = $post; + } + public function isPost(): bool + { + return $this->post; + } + }; + } + + public function redirect() + { + $outer = $this; + return new class($outer) { + private TestableStylesController $outer; + public function __construct(TestableStylesController $outer) + { + $this->outer = $outer; + } + public function toRoute(string $name) + { + $this->outer->lastRedirect = ['redirect' => $name]; + return null; + } + }; + } + + public function messenger() + { + $outer = $this; + return new class($outer) { + private TestableStylesController $outer; + public function __construct(TestableStylesController $outer) + { + $this->outer = $outer; + } + public function addSuccess(string $msg): void + { + $this->outer->messengerSuccess[] = $msg; + } + public function addError(string $msg): void + { + $this->outer->messengerError[] = $msg; + } + }; + } + + protected function allowed(): bool + { + return $this->allowedFlag; + } +} diff --git a/test/ExeLearningTest/Controller/StylesServeControllerTest.php b/test/ExeLearningTest/Controller/StylesServeControllerTest.php new file mode 100644 index 0000000..40f0958 --- /dev/null +++ b/test/ExeLearningTest/Controller/StylesServeControllerTest.php @@ -0,0 +1,188 @@ +tmpRoot = sys_get_temp_dir() . '/exelearning-serve-' . uniqid(); + mkdir($this->tmpRoot . '/files', 0755, true); + mkdir($this->tmpRoot . '/module', 0755, true); + $this->svc = new StylesService(new Settings(), $this->tmpRoot . '/files', $this->tmpRoot . '/module'); + $this->controller = new StylesServeController($this->svc); + } + + protected function tearDown(): void + { + StylesService::recursiveDelete($this->tmpRoot); + parent::tearDown(); + } + + public function testGuessMimeTypeCoversEveryAllowedExtension(): void + { + $expected = [ + 'a.css' => 'text/css', + 'a.js' => 'application/javascript', + 'a.map' => 'application/json', + 'a.json' => 'application/json', + 'a.xml' => 'application/xml', + 'a.html' => 'text/html', + 'a.htm' => 'text/html', + 'a.md' => 'text/markdown', + 'a.txt' => 'text/plain', + 'a.svg' => 'image/svg+xml', + 'a.png' => 'image/png', + 'a.jpg' => 'image/jpeg', + 'a.jpeg' => 'image/jpeg', + 'a.gif' => 'image/gif', + 'a.webp' => 'image/webp', + 'a.ico' => 'image/vnd.microsoft.icon', + 'a.woff' => 'font/woff', + 'a.woff2' => 'font/woff2', + 'a.ttf' => 'font/ttf', + 'a.otf' => 'font/otf', + 'a.eot' => 'application/vnd.ms-fontobject', + 'a.unknown' => 'application/octet-stream', + 'README' => 'application/octet-stream', + ]; + foreach ($expected as $name => $expectedType) { + $actual = $this->invokePrivate('guessMimeType', [$name]); + $this->assertSame($expectedType, $actual, "MIME for $name"); + } + } + + public function testNotFoundReturns404Response(): void + { + // Inject a minimal Response stub and exercise the notFound() helper. + $response = new \Laminas\Http\Response(); + $ref = new ReflectionClass($this->controller); + $prop = $ref->getProperty('response'); + $prop->setAccessible(true); + $prop->setValue($this->controller, $response); + + $result = $this->invokePrivate('notFound', []); + $this->assertSame(404, $result->getStatusCode()); + $this->assertSame('Not found', $result->getContent()); + } + + public function testServeStyleReturns404ForUnregisteredSlug(): void + { + $this->bindResponse(); + $result = $this->controller->serveStyle('nope', 'style.css'); + $this->assertSame(404, $result->getStatusCode()); + } + + public function testServeStyleRefusesTraversal(): void + { + $this->installFakeStyle('acme'); + $this->bindResponse(); + foreach ([ + ['..', 'style.css'], + ['acme', '../secret.css'], + ['', 'style.css'], + ] as [$slug, $file]) { + $result = $this->controller->serveStyle($slug, $file); + $this->assertSame(404, $result->getStatusCode(), "$slug|$file"); + } + } + + public function testServeStyleReturnsFileContentsWithHeaders(): void + { + $this->installFakeStyle('acme', 'body { color: red; }'); + $this->bindResponse(); + $result = $this->controller->serveStyle('acme', 'style.css'); + $this->assertSame(200, $result->getStatusCode()); + $this->assertStringContainsString('color: red', $result->getContent()); + } + + public function testServeStyleDefaultsToStyleCssWhenFileEmpty(): void + { + $this->installFakeStyle('acme', '.x{}'); + $this->bindResponse(); + $result = $this->controller->serveStyle('acme', 'style.css'); + $this->assertSame(200, $result->getStatusCode()); + } + + public function testServeStyle404WhenPathEscapesDir(): void + { + $this->installFakeStyle('acme'); + $this->bindResponse(); + // An entry that does not exist in the style dir. + $result = $this->controller->serveStyle('acme', 'missing.css'); + $this->assertSame(404, $result->getStatusCode()); + } + + public function testServeActionDelegatesToServeStyleWithDefaults(): void + { + // Simulate a controller whose params() returns the defaults we + // pass (via a lightweight subclass). Confirms serveAction is a + // thin wire between params() and serveStyle(). + $this->installFakeStyle('acme'); + $this->bindResponse(); + $sub = new class($this->svc) extends StylesServeController { + public function params($name = null, $default = null) + { + return $default; + } + }; + $ref = new ReflectionClass($sub); + $prop = $ref->getProperty('response'); + $prop->setAccessible(true); + $prop->setValue($sub, new \Laminas\Http\Response()); + $result = $sub->serveAction(); + // slug default is '' → 404 is expected. + $this->assertSame(404, $result->getStatusCode()); + } + + // ------------------------------------------------------------------ + // helpers + // ------------------------------------------------------------------ + + private function invokePrivate(string $name, array $args) + { + $ref = new ReflectionClass($this->controller); + $method = $ref->getMethod($name); + $method->setAccessible(true); + return $method->invokeArgs($this->controller, $args); + } + + private function bindResponse(): void + { + $response = new \Laminas\Http\Response(); + $ref = new ReflectionClass($this->controller); + $prop = $ref->getProperty('response'); + $prop->setAccessible(true); + $prop->setValue($this->controller, $response); + } + + private function installFakeStyle(string $slug, string $cssBody = 'body{}'): void + { + $zipPath = $this->tmpRoot . '/' . $slug . '.zip'; + $zip = new \ZipArchive(); + $zip->open($zipPath, \ZipArchive::CREATE); + $zip->addFromString('config.xml', + '' . $slug . '' + . '' . ucfirst($slug) . '1.0' + ); + $zip->addFromString('style.css', $cssBody); + $zip->close(); + $this->svc->installFromZip($zipPath, $slug . '.zip'); + @unlink($zipPath); + } +} diff --git a/test/ExeLearningTest/Form/StylesUploadFormTest.php b/test/ExeLearningTest/Form/StylesUploadFormTest.php new file mode 100644 index 0000000..caa4673 --- /dev/null +++ b/test/ExeLearningTest/Form/StylesUploadFormTest.php @@ -0,0 +1,40 @@ +init(); + + $this->assertSame('post', $form->getAttribute('method')); + $this->assertSame('multipart/form-data', $form->getAttribute('enctype')); + + $this->assertTrue($form->has('styles_zip')); + $this->assertTrue($form->has('csrf')); + $this->assertTrue($form->has('submit')); + + $fileElement = $form->get('styles_zip'); + $this->assertSame('multiple', $fileElement->getAttribute('multiple')); + $this->assertStringContainsString('.zip', $fileElement->getAttribute('accept')); + $this->assertSame('styles_zip', $fileElement->getAttribute('id')); + $this->assertTrue((bool) $fileElement->getAttribute('required')); + } + + public function testSubmitElementIsRegisteredWithButtonClass(): void + { + $form = new StylesUploadForm('styles_upload'); + $form->init(); + $submit = $form->get('submit'); + $this->assertSame('button', $submit->getAttribute('class')); + } +} diff --git a/test/ExeLearningTest/Service/StylesServiceTest.php b/test/ExeLearningTest/Service/StylesServiceTest.php new file mode 100644 index 0000000..3cd4372 --- /dev/null +++ b/test/ExeLearningTest/Service/StylesServiceTest.php @@ -0,0 +1,662 @@ +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'] + ); + // Even icon-less styles must expose the field so the editor's + // Theme constructor doesn't fall back to its empty default. + $this->assertArrayHasKey('icons', $override['uploaded'][0]); + $this->assertSame([], $override['uploaded'][0]['icons']); + + $this->svc->setUploadedEnabled('seen', false); + $override = $this->svc->buildThemeRegistryOverride(); + $this->assertCount(0, $override['uploaded']); + @unlink($zip); + } + + public function testBuildOverridePublishesIconsFromIconsFolder(): void + { + $zip = $this->makeZip([ + 'config.xml' => $this->configXml('iconic'), + 'style.css' => 'a{}', + 'icons/activity.png' => 'PNG', + 'icons/alert.svg' => '', + 'icons/photo.JPG' => 'JPEG', + 'icons/readme.txt' => 'ignore', + 'icons/no-extension' => 'ignore', + ]); + $this->svc->installFromZip($zip); + + $override = $this->svc->buildThemeRegistryOverride('https://host'); + $this->assertCount(1, $override['uploaded']); + $entry = $override['uploaded'][0]; + $this->assertSame('iconic', $entry['id']); + $this->assertArrayHasKey('icons', $entry); + $this->assertSame(['activity', 'alert', 'photo'], array_keys($entry['icons'])); + $activity = $entry['icons']['activity']; + $this->assertSame('activity', $activity['id']); + $this->assertSame('activity', $activity['title']); + $this->assertSame('img', $activity['type']); + $this->assertSame($entry['url'] . '/icons/activity.png', $activity['value']); + @unlink($zip); + } + + public function testScanUploadedIconsReturnsEmptyWhenIconsFolderMissing(): void + { + $this->assertSame( + [], + $this->svc->scanUploadedIcons('no-such-style', 'http://example.test/styles/no-such-style') + ); + } + + public function testScanUploadedIconsUrlEncodesFilenamesWithSpaces(): void + { + $zip = $this->makeZip([ + 'config.xml' => $this->configXml('spaced'), + 'style.css' => 'a{}', + 'icons/my activity.png' => 'PNG', + ]); + $this->svc->installFromZip($zip); + + $icons = $this->svc->scanUploadedIcons('spaced', 'http://example.test/styles/spaced'); + $this->assertArrayHasKey('my activity', $icons); + $this->assertSame( + 'http://example.test/styles/spaced/icons/my%20activity.png', + $icons['my activity']['value'] + ); + @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'])); + // Rows without a name are skipped. + $this->assertSame([], $this->svc->extractThemesFromBundle(['themes' => [[], ['title' => 'x']]])); + } + + public function testListBuiltinThemesReadsBundleWhenPresent(): void + { + $dataDir = $this->tmpRoot . '/module/dist/static/data'; + mkdir($dataDir, 0755, true); + file_put_contents($dataDir . '/bundle.json', json_encode([ + 'themes' => ['themes' => [ + ['name' => 'base', 'title' => 'Default', 'version' => '2025'], + ['name' => 'neo', 'title' => 'Neo'], + ]], + ])); + $themes = $this->svc->listBuiltinThemes(); + $this->assertCount(2, $themes); + $this->assertSame('base', $themes[0]['id']); + $this->assertSame('Default', $themes[0]['title']); + } + + public function testListBuiltinThemesReturnsEmptyWhenBundleMissing(): void + { + $this->assertSame([], $this->svc->listBuiltinThemes()); + } + + public function testListBuiltinThemesReturnsEmptyOnMalformedJson(): void + { + $dataDir = $this->tmpRoot . '/module/dist/static/data'; + mkdir($dataDir, 0755, true); + file_put_contents($dataDir . '/bundle.json', '{not json'); + $this->assertSame([], $this->svc->listBuiltinThemes()); + } + + public function testAllocateUniqueSlugSuffixesAroundBuiltinsAndUploads(): void + { + $dataDir = $this->tmpRoot . '/module/dist/static/data'; + mkdir($dataDir, 0755, true); + file_put_contents($dataDir . '/bundle.json', json_encode([ + 'themes' => ['themes' => [['name' => 'acme']]], + ])); + // Bound against a built-in name -> suffix -2. + $slug = $this->svc->allocateUniqueSlug('acme'); + $this->assertSame('acme-2', $slug); + + // After installing 'acme-2', the next collision goes to -3. + $zip = $this->makeZip(['config.xml' => $this->configXml('acme-2'), 'style.css' => 'x{}']); + $this->svc->installFromZip($zip); + $this->assertSame('acme-3', $this->svc->allocateUniqueSlug('acme')); + @unlink($zip); + } + + public function testNormalizeSlugSanitizesInput(): void + { + $this->assertSame('a-b-c', StylesService::normalizeSlug('A B C')); + $this->assertSame('style', StylesService::normalizeSlug(' ')); + $this->assertSame('a', StylesService::normalizeSlug('---a---')); + } + + public function testIsUnsafeZipEntryDetectsEveryBadShape(): void + { + foreach (['', '\\a', '/absolute', 'http://x', '../x', 'a/../b'] as $bad) { + $this->assertTrue(StylesService::isUnsafeZipEntry($bad), "should reject: $bad"); + } + foreach (['style.css', 'icons/a.png', 'sub/dir/file.css'] as $ok) { + $this->assertFalse(StylesService::isUnsafeZipEntry($ok), "should accept: $ok"); + } + } + + public function testIsAllowedFilenameRejectsExtensionlessAndPhp(): void + { + $this->assertFalse(StylesService::isAllowedFilename('README')); + $this->assertFalse(StylesService::isAllowedFilename('evil.php')); + $this->assertTrue(StylesService::isAllowedFilename('dir/')); + $this->assertTrue(StylesService::isAllowedFilename('style.css')); + $this->assertTrue(StylesService::isAllowedFilename('icons/a.svg')); + } + + public function testParseConfigXmlRejectsInvalidXml(): void + { + $this->expectException(\RuntimeException::class); + StylesService::parseConfigXml('<expectException(\RuntimeException::class); + StylesService::parseConfigXml(''); + } + + public function testValidateZipRejectsOversize(): void + { + // Make a zip with large-content to exceed the cap via a reflective + // tweak of the service's default max. + $zip = $this->makeZip([ + 'config.xml' => $this->configXml('big'), + 'style.css' => str_repeat('x', 100), + ]); + // Force max size to 50 bytes via an anonymous subclass. + $svc = new class(new Settings(), $this->tmpRoot . '/files', $this->tmpRoot . '/module') extends StylesService { + public function getMaxZipSize(): int + { + return 50; + } + }; + $this->expectException(\RuntimeException::class); + try { + $svc->validateZip($zip); + } finally { + @unlink($zip); + } + } + + public function testValidateZipAcceptsSingleRootFolder(): void + { + $zip = $this->makeZip([ + 'acme/config.xml' => $this->configXml('acme'), + 'acme/style.css' => 'body{}', + ]); + $result = $this->svc->validateZip($zip); + $this->assertSame('acme/', $result['prefix']); + @unlink($zip); + } + + public function testValidateZipRejectsMultipleConfigs(): void + { + $zip = $this->makeZip([ + 'a/config.xml' => $this->configXml('a'), + 'b/config.xml' => $this->configXml('b'), + 'a/style.css' => 'x{}', + 'b/style.css' => 'y{}', + ]); + $this->expectException(\RuntimeException::class); + try { + $this->svc->validateZip($zip); + } finally { + @unlink($zip); + } + } + + public function testInstallFromFolderedZipExtractsOnlyFiles(): void + { + $zip = $this->makeZip([ + 'acme/config.xml' => $this->configXml('acme'), + 'acme/style.css' => 'body{}', + 'acme/img/bg.png' => 'fake-png', + ]); + $entry = $this->svc->installFromZip($zip); + $this->assertFileExists($this->svc->getStorageDir() . '/acme/style.css'); + $this->assertFileExists($this->svc->getStorageDir() . '/acme/img/bg.png'); + $this->assertFileDoesNotExist($this->svc->getStorageDir() . '/acme/acme/style.css'); + @unlink($zip); + } + + public function testInstallRejectsWhenNoCssIsPresent(): void + { + $zip = $this->makeZip([ + 'config.xml' => $this->configXml('nocss'), + 'info.md' => 'no css here', + ]); + $this->expectException(\RuntimeException::class); + try { + $this->svc->installFromZip($zip); + } finally { + @unlink($zip); + } + } + + public function testBuildOverrideHonorsBaseUrl(): void + { + $zip = $this->makeZip([ + 'config.xml' => $this->configXml('acme'), + 'style.css' => 'x{}', + ]); + $this->svc->installFromZip($zip); + $override = $this->svc->buildThemeRegistryOverride('https://host'); + $this->assertSame('https://host/exelearning/styles/acme', $override['uploaded'][0]['url']); + + $override2 = $this->svc->buildThemeRegistryOverride(''); + $this->assertSame('/exelearning/styles/acme', $override2['uploaded'][0]['url']); + @unlink($zip); + } + + public function testGetStorageAndStyleDirResolveCorrectly(): void + { + $this->assertStringEndsWith('/exelearning-styles', $this->svc->getStorageDir()); + $this->assertStringEndsWith('/exelearning-styles/acme', $this->svc->getStyleDir('acme')); + $this->assertStringEndsWith('/exelearning-styles/style', $this->svc->getStyleDir('')); + } + + public function testRecursiveDeleteHandlesMissingPath(): void + { + // Should not throw. + StylesService::recursiveDelete($this->tmpRoot . '/does-not-exist'); + $this->assertTrue(true); + } + + public function testSetUploadedEnabledReturnsFalseForUnknownSlug(): void + { + $this->assertFalse($this->svc->setUploadedEnabled('missing', true)); + } + + public function testDeleteUploadedReturnsFalseForUnknownSlug(): void + { + $this->assertFalse($this->svc->deleteUploaded('missing')); + } + + public function testGetStyleUrlPathIsSlugScopedAndEncoded(): void + { + $this->assertSame('/exelearning/styles/weird-name', $this->svc->getStyleUrlPath('weird name')); + $this->assertSame('/exelearning/styles/acme', $this->svc->getStyleUrlPath('ACME')); + } + + public function testListUploadedStylesSkipsNonArrayEntries(): void + { + // Inject a malformed entry and confirm the listing filters it out. + $settings = new Settings(); + $svc = new StylesService($settings, $this->tmpRoot . '/files', $this->tmpRoot . '/module'); + $settings->set(StylesService::SETTING_REGISTRY, json_encode([ + 'uploaded' => [ + 'good' => ['title' => 'Good', 'enabled' => true, 'css_files' => ['style.css']], + 'bad' => 'this is not an array', + ], + ])); + $list = $svc->listUploadedStyles(); + $this->assertCount(1, $list); + $this->assertSame('good', $list[0]['id']); + } + + public function testBuildOverrideSkipsNonArrayOrDisabledEntries(): void + { + $settings = new Settings(); + $svc = new StylesService($settings, $this->tmpRoot . '/files', $this->tmpRoot . '/module'); + $settings->set(StylesService::SETTING_REGISTRY, json_encode([ + 'uploaded' => [ + 'off' => ['title' => 'Off', 'enabled' => false], + 'bad' => 'scalar', + 'on' => ['title' => 'On', 'enabled' => true, 'css_files' => ['style.css']], + ], + ])); + $override = $svc->buildThemeRegistryOverride(); + $this->assertCount(1, $override['uploaded']); + $this->assertSame('on', $override['uploaded'][0]['id']); + } + + public function testGetRegistryGracefullyHandlesGarbageSettingValue(): void + { + $settings = new Settings(); + $svc = new StylesService($settings, $this->tmpRoot . '/files', $this->tmpRoot . '/module'); + // Not JSON at all. + $settings->set(StylesService::SETTING_REGISTRY, 'not json'); + $r = $svc->getRegistry(); + $this->assertSame([], $r['uploaded']); + $this->assertSame([], $r['disabled_builtins']); + + // JSON but not an object. + $settings->set(StylesService::SETTING_REGISTRY, '[1,2,3]'); + $r = $svc->getRegistry(); + $this->assertSame([], $r['uploaded']); + $this->assertSame([], $r['disabled_builtins']); + + // JSON object with wrong-typed fields. + $settings->set(StylesService::SETTING_REGISTRY, '{"uploaded":"nope","disabled_builtins":"nope"}'); + $r = $svc->getRegistry(); + $this->assertSame([], $r['uploaded']); + $this->assertSame([], $r['disabled_builtins']); + } + + public function testValidateZipRejectsEmptyFile(): void + { + $empty = tempnam(sys_get_temp_dir(), 'empty') . '.zip'; + file_put_contents($empty, ''); + $this->expectException(\RuntimeException::class); + try { + $this->svc->validateZip($empty); + } finally { + @unlink($empty); + } + } + + public function testValidateZipRejectsMissingFile(): void + { + $this->expectException(\RuntimeException::class); + $this->svc->validateZip($this->tmpRoot . '/does-not-exist.zip'); + } + + public function testValidateZipRejectsNonZipPayload(): void + { + $notzip = tempnam(sys_get_temp_dir(), 'notzip') . '.zip'; + file_put_contents($notzip, 'this is not a zip archive'); + $this->expectException(\RuntimeException::class); + try { + $this->svc->validateZip($notzip); + } finally { + @unlink($notzip); + } + } + + public function testInstallSurvivesConfigXmlWithMinimumFields(): void + { + $zip = $this->makeZip([ + 'config.xml' => 'min', + 'style.css' => 'x{}', + ]); + $entry = $this->svc->installFromZip($zip); + $this->assertSame('min', $entry['name']); + // No title in config -> derived from name. + $this->assertSame('min', $entry['title']); + $this->assertSame('', $entry['version']); + @unlink($zip); + } + + public function testHashZipPrefixesSha256(): void + { + $zip = $this->makeZip([ + 'config.xml' => $this->configXml('hashy'), + 'style.css' => 'x{}', + ]); + $entry = $this->svc->installFromZip($zip); + $this->assertStringStartsWith('sha256:', $entry['checksum']); + $this->assertSame(64, strlen(substr($entry['checksum'], 7))); + @unlink($zip); + } + + public function testFindCssFilesPrioritizesStyleCss(): void + { + $zip = $this->makeZip([ + 'config.xml' => $this->configXml('multi'), + 'style.css' => '/* primary */', + 'extra.css' => '/* extra */', + 'zzz-last.css' => '/* sort-after-extra */', + ]); + $entry = $this->svc->installFromZip($zip); + $this->assertSame('style.css', $entry['css_files'][0]); + $this->assertCount(3, $entry['css_files']); + @unlink($zip); + } + + public function testFindCssFilesStillWorksWithoutStyleCss(): void + { + $zip = $this->makeZip([ + 'config.xml' => $this->configXml('alt'), + 'main.css' => '/* main */', + ]); + $entry = $this->svc->installFromZip($zip); + $this->assertSame(['main.css'], $entry['css_files']); + @unlink($zip); + } + + public function testValidateRejectsArchiveWhoseConfigIsNotAtPrefix(): void + { + $zip = $this->makeZip([ + 'acme/config.xml' => $this->configXml('acme'), + 'outside.css' => 'leak', + ]); + $this->expectException(\RuntimeException::class); + try { + $this->svc->validateZip($zip); + } finally { + @unlink($zip); + } + } + + public function testRecursiveDeleteRemovesNestedFilesAndDirs(): void + { + $root = $this->tmpRoot . '/delete-target'; + mkdir($root . '/inner/deep', 0755, true); + file_put_contents($root . '/a.txt', 'a'); + file_put_contents($root . '/inner/b.txt', 'b'); + file_put_contents($root . '/inner/deep/c.txt', 'c'); + $this->assertDirectoryExists($root); + StylesService::recursiveDelete($root); + $this->assertDirectoryDoesNotExist($root); + } + + // ---------- 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/Laminas/Mvc/MvcEvent.php b/test/Stubs/Laminas/Mvc/MvcEvent.php new file mode 100644 index 0000000..9b4d3ef --- /dev/null +++ b/test/Stubs/Laminas/Mvc/MvcEvent.php @@ -0,0 +1,26 @@ +routeMatch = $match; + return $this; + } + + public function getRouteMatch(): ?RouteMatch + { + return $this->routeMatch; + } +} diff --git a/test/Stubs/Laminas/Router/RouteMatch.php b/test/Stubs/Laminas/Router/RouteMatch.php new file mode 100644 index 0000000..584250f --- /dev/null +++ b/test/Stubs/Laminas/Router/RouteMatch.php @@ -0,0 +1,49 @@ +params()->fromRoute()` / + * `$this->params('slug')`). Mirrors the fields the Laminas MVC param + * plugin reads off a RouteMatch instance. + */ +class RouteMatch +{ + /** @var array */ + private array $params; + private ?string $matchedRouteName = null; + + public function __construct(array $params = []) + { + $this->params = $params; + } + + public function getParam(string $name, $default = null) + { + return $this->params[$name] ?? $default; + } + + public function getParams(): array + { + return $this->params; + } + + public function setParam(string $name, $value): self + { + $this->params[$name] = $value; + return $this; + } + + public function setMatchedRouteName(string $name): self + { + $this->matchedRouteName = $name; + return $this; + } + + public function getMatchedRouteName(): ?string + { + return $this->matchedRouteName; + } +} 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..465459d --- /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.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..28f773f 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 = <<