From af19e89b658514a0778ee005572a9048ee04e8d6 Mon Sep 17 00:00:00 2001 From: erseco Date: Thu, 23 Apr 2026 18:53:49 +0100 Subject: [PATCH 01/13] 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 an admin Styles management page (Site administration → Plugins → Activity modules → eXeLearning (website) → Styles) where managers can upload eXeLearning style .zip packages, enable/disable built-in styles, and enable/disable/delete uploaded ones — without rebuilding the static editor bundle. Architecture - `mod_exeweb\local\styles_service` owns the pure logic: ZIP validation (path traversal, absolute paths, size cap, extension allow-list, single config.xml), slug allocation with collision suffix, registry persistence in config_plugin(exeweb), and the `build_theme_registry_override()` payload the editor consumes. - `admin/styles.php` is a standard Moodle admin_externalpage that renders the upload form and lists, using HTTP POST + sesskey (no custom AJAX). - `editor/styles.php/{slug}/{file}` serves the extracted style assets from moodledata with PATH_INFO, gated by site capability checks and a registry membership check. - Uploaded style bundles extract to `{dataroot}/mod_exeweb/styles/{slug}/` — a sibling of `mod_exeweb/embedded_editor/` so reinstalling the embedded editor never destroys admin-managed styles. - `editor/index.php` injects `window.eXeLearning.config.themeRegistryOverride` and mirrors `blockImportInstall` onto the pre-existing `userStyles` (ONLINE_THEMES_INSTALL) flag so the 'Import this project style?' modal is suppressed end-to-end. Admin toggle - `stylesblockimport` (default: 1) controls whether imported project styles are refused. When on, the editor hides the 'Imported' tab (see companion core PR) and silently falls back to the default style instead of offering to install. Behavior - Disabled built-ins disappear from the editor's selector. - Uploaded styles show up alongside built-ins with stable ids. - Projects referencing a missing/disabled style fall back to `base`. - The admin link appears only when editor mode is 'embedded'. Tests - `tests/local/styles_service_test.php`: ZIP validator edge cases, install, unique-slug on collision, delete, override enabled flag, import-blocked config contract. Language - Adds strings under `styles*` in `lang/en/exeweb.php`. Depends on - Core editor hook: exelearning/exelearning#1722 (merged). - Core editor UI follow-up: exelearning/exelearning#1724 (hides the 'Imported' tab when blockImportInstall is set). --- admin/styles.php | 230 +++++++++ classes/local/styles_service.php | 710 ++++++++++++++++++++++++++++ editor/index.php | 16 + editor/styles.php | 112 +++++ lang/en/exeweb.php | 47 ++ settings.php | 38 ++ tests/local/styles_service_test.php | 236 +++++++++ 7 files changed, 1389 insertions(+) create mode 100644 admin/styles.php create mode 100644 classes/local/styles_service.php create mode 100644 editor/styles.php create mode 100644 tests/local/styles_service_test.php diff --git a/admin/styles.php b/admin/styles.php new file mode 100644 index 0000000..7b644fb --- /dev/null +++ b/admin/styles.php @@ -0,0 +1,230 @@ +. + +/** + * Admin page for managing eXeLearning styles exposed to the embedded editor. + * + * @package mod_exeweb + * @copyright 2025 eXeLearning + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +require('../../../config.php'); +require_once($CFG->libdir . '/adminlib.php'); + +use mod_exeweb\local\styles_service; + +admin_externalpage_setup('mod_exeweb_styles'); + +$context = \context_system::instance(); +require_capability('moodle/site:config', $context); +require_capability('mod/exeweb:manageembeddededitor', $context); + +$action = optional_param('action', '', PARAM_ALPHA); +$returnurl = new moodle_url('/mod/exeweb/admin/styles.php'); + +// -------------------------------------------------------------------- +// Actions. +// -------------------------------------------------------------------- +if ($action !== '') { + require_sesskey(); + switch ($action) { + case 'upload': + $uploaded = $_FILES['style_zip'] ?? null; + if (!is_array($uploaded) || (int) $uploaded['error'] !== UPLOAD_ERR_OK) { + redirect($returnurl, get_string('stylesupload_failed', 'mod_exeweb'), null, + \core\output\notification::NOTIFY_ERROR); + } + if (!is_uploaded_file($uploaded['tmp_name'])) { + redirect($returnurl, get_string('stylesupload_failed', 'mod_exeweb'), null, + \core\output\notification::NOTIFY_ERROR); + } + try { + $entry = styles_service::install_from_zip( + $uploaded['tmp_name'], + clean_param($uploaded['name'] ?? '', PARAM_FILE) + ); + redirect($returnurl, + get_string('stylesupload_success', 'mod_exeweb', s($entry['title'])), + null, + \core\output\notification::NOTIFY_SUCCESS + ); + } catch (\moodle_exception $e) { + redirect($returnurl, $e->getMessage(), null, + \core\output\notification::NOTIFY_ERROR); + } + break; + + case 'toggleuploaded': + $slug = required_param('slug', PARAM_TEXT); + $enabled = (bool) required_param('enabled', PARAM_INT); + styles_service::set_uploaded_enabled($slug, $enabled); + redirect($returnurl); + break; + + case 'togglebuiltin': + $id = required_param('id', PARAM_TEXT); + $enabled = (bool) required_param('enabled', PARAM_INT); + styles_service::set_builtin_enabled($id, $enabled); + redirect($returnurl); + break; + + case 'delete': + $slug = required_param('slug', PARAM_TEXT); + styles_service::delete_uploaded($slug); + redirect($returnurl, + get_string('stylesdelete_success', 'mod_exeweb'), + null, + \core\output\notification::NOTIFY_SUCCESS + ); + break; + } +} + +// -------------------------------------------------------------------- +// Render. +// -------------------------------------------------------------------- +echo $OUTPUT->header(); +echo $OUTPUT->heading(get_string('stylesmanager', 'mod_exeweb')); + +if (get_config('exeweb', 'editormode') !== 'embedded') { + echo $OUTPUT->notification(get_string('stylesonlywhenembedded', 'mod_exeweb'), + \core\output\notification::NOTIFY_WARNING); +} + +echo html_writer::tag('p', get_string('stylesmanager_intro', 'mod_exeweb')); + +// Upload form. +echo html_writer::start_tag('form', [ + 'method' => 'post', + 'action' => $returnurl->out(false), + 'enctype' => 'multipart/form-data', + 'class' => 'mt-3 mb-4', +]); +echo html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'action', 'value' => 'upload']); +echo html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'sesskey', 'value' => sesskey()]); +echo html_writer::tag('label', + get_string('stylesupload_label', 'mod_exeweb'), + ['for' => 'style_zip', 'class' => 'd-block mb-1'] +); +echo html_writer::empty_tag('input', [ + 'type' => 'file', + 'id' => 'style_zip', + 'name' => 'style_zip', + 'accept' => '.zip,application/zip,application/x-zip-compressed', + 'required' => 'required', +]); +echo ' '; +echo html_writer::empty_tag('input', [ + 'type' => 'submit', + 'class' => 'btn btn-primary', + 'value' => get_string('stylesupload_submit', 'mod_exeweb'), +]); +echo html_writer::tag('p', + get_string('stylesupload_hint', 'mod_exeweb', + display_size(styles_service::get_max_zip_size())), + ['class' => 'text-muted small mt-2'] +); +echo html_writer::end_tag('form'); + +// Uploaded styles table. +$uploaded = styles_service::list_uploaded_styles(); +echo $OUTPUT->heading(get_string('stylesuploaded', 'mod_exeweb'), 3); +if (empty($uploaded)) { + echo html_writer::tag('p', get_string('stylesuploaded_empty', 'mod_exeweb'), ['class' => 'text-muted']); +} else { + $table = new html_table(); + $table->head = [ + get_string('stylestable_title', 'mod_exeweb'), + get_string('stylestable_id', 'mod_exeweb'), + get_string('stylestable_version', 'mod_exeweb'), + get_string('stylestable_installed', 'mod_exeweb'), + get_string('stylestable_enabled', 'mod_exeweb'), + get_string('stylestable_actions', 'mod_exeweb'), + ]; + foreach ($uploaded as $style) { + $toggleurl = new moodle_url('/mod/exeweb/admin/styles.php', [ + 'action' => 'toggleuploaded', + 'slug' => $style['id'], + 'enabled' => empty($style['enabled']) ? 1 : 0, + 'sesskey' => sesskey(), + ]); + $togglelabel = empty($style['enabled']) + ? get_string('stylesenable', 'mod_exeweb') + : get_string('stylesdisable', 'mod_exeweb'); + $deleteurl = new moodle_url('/mod/exeweb/admin/styles.php', [ + 'action' => 'delete', + 'slug' => $style['id'], + 'sesskey' => sesskey(), + ]); + $table->data[] = [ + s($style['title'] ?? $style['id']), + html_writer::tag('code', s($style['id'])), + s($style['version'] ?? ''), + s($style['installed_at'] ?? ''), + html_writer::link($toggleurl, $togglelabel, ['class' => 'btn btn-secondary btn-sm']), + html_writer::link( + $deleteurl, + get_string('stylesdelete', 'mod_exeweb'), + [ + 'class' => 'btn btn-danger btn-sm', + 'onclick' => "return confirm('" + . addslashes_js(get_string('stylesdelete_confirm', 'mod_exeweb')) + . "');", + ] + ), + ]; + } + echo html_writer::table($table); +} + +// Built-in styles table. +$builtins = styles_service::list_builtin_themes(); +echo $OUTPUT->heading(get_string('stylesbuiltin', 'mod_exeweb'), 3); +if (empty($builtins)) { + echo html_writer::tag('p', get_string('stylesbuiltin_empty', 'mod_exeweb'), ['class' => 'text-muted']); +} else { + $registry = styles_service::get_registry(); + $disabledlist = $registry['disabled_builtins']; + $table = new html_table(); + $table->head = [ + get_string('stylestable_title', 'mod_exeweb'), + get_string('stylestable_id', 'mod_exeweb'), + get_string('stylestable_version', 'mod_exeweb'), + get_string('stylestable_enabled', 'mod_exeweb'), + ]; + foreach ($builtins as $style) { + $isdisabled = in_array($style['id'], $disabledlist, true); + $toggleurl = new moodle_url('/mod/exeweb/admin/styles.php', [ + 'action' => 'togglebuiltin', + 'id' => $style['id'], + 'enabled' => $isdisabled ? 1 : 0, + 'sesskey' => sesskey(), + ]); + $togglelabel = $isdisabled + ? get_string('stylesenable', 'mod_exeweb') + : get_string('stylesdisable', 'mod_exeweb'); + $table->data[] = [ + s($style['title']), + html_writer::tag('code', s($style['id'])), + s($style['version']), + html_writer::link($toggleurl, $togglelabel, ['class' => 'btn btn-secondary btn-sm']), + ]; + } + echo html_writer::table($table); +} + +echo $OUTPUT->footer(); diff --git a/classes/local/styles_service.php b/classes/local/styles_service.php new file mode 100644 index 0000000..0b56437 --- /dev/null +++ b/classes/local/styles_service.php @@ -0,0 +1,710 @@ +. + +/** + * Style package registry and ZIP validator for mod_exeweb. + * + * Administrators upload eXeLearning style packages as .zip files. This + * service validates them, extracts them into moodledata, records metadata + * in `config_plugin(exeweb)`, and builds the registry payload that the + * embedded editor consumes via `window.eXeLearning.config.themeRegistryOverride`. + * + * Uploaded styles live at: + * {dataroot}/mod_exeweb/styles/{slug}/ + * which is a *sibling* of the admin-installed editor directory + * ({dataroot}/mod_exeweb/embedded_editor/), so reinstalling the editor + * never destroys admin-managed styles. + * + * @package mod_exeweb + * @copyright 2025 eXeLearning + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_exeweb\local; + +/** + * Class styles_service. + */ +class styles_service { + + /** @var string Subdirectory under $CFG->dataroot holding uploaded styles. */ + const MOODLEDATA_SUBDIR = 'mod_exeweb/styles'; + + /** @var string Plugin config key storing the serialized registry. */ + const CONFIG_REGISTRY = 'styles_registry'; + + /** @var int Default max allowed ZIP size (20 MB). */ + const DEFAULT_MAX_ZIP_SIZE = 20971520; + + /** @var string[] Allow-list of extensions inside an uploaded style ZIP. */ + const ALLOWED_EXTENSIONS = [ + 'css', 'js', 'map', 'svg', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'ico', + 'xml', 'json', 'md', 'txt', 'html', 'htm', + 'woff', 'woff2', 'ttf', 'otf', 'eot', + ]; + + // --------------------------------------------------------------------- + // Storage helpers + // --------------------------------------------------------------------- + + /** + * Absolute path to the directory that stores uploaded styles. + * + * @return string + */ + public static function get_storage_dir(): string { + global $CFG; + return $CFG->dataroot . '/' . self::MOODLEDATA_SUBDIR; + } + + /** + * Absolute path for a specific uploaded style. + * + * @param string $slug + * @return string + */ + public static function get_style_dir(string $slug): string { + return self::get_storage_dir() . '/' . self::normalize_slug($slug); + } + + /** + * Build the public URL prefix (served via editor/styles.php) for a slug. + * + * @param string $slug + * @return string + */ + public static function get_style_url(string $slug): string { + global $CFG; + return $CFG->wwwroot . '/mod/exeweb/editor/styles.php/' . rawurlencode(self::normalize_slug($slug)); + } + + /** + * Maximum allowed upload size in bytes. + * + * @return int + */ + public static function get_max_zip_size(): int { + $configured = (int) get_config('exeweb', 'styles_max_zip_size'); + return $configured > 0 ? $configured : self::DEFAULT_MAX_ZIP_SIZE; + } + + // --------------------------------------------------------------------- + // Registry persistence + // --------------------------------------------------------------------- + + /** + * Load the persisted registry. + * + * @return array{uploaded: array, disabled_builtins: string[]} + */ + public static function get_registry(): array { + $raw = get_config('exeweb', self::CONFIG_REGISTRY); + if (!is_string($raw) || $raw === '' || $raw === 'false') { + $data = []; + } else { + $decoded = json_decode($raw, true); + $data = is_array($decoded) ? $decoded : []; + } + 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'])) + : [], + ]; + } + + /** + * Persist the registry. + * + * @param array $registry + */ + public static function save_registry(array $registry): void { + set_config(self::CONFIG_REGISTRY, json_encode($registry), 'exeweb'); + } + + // --------------------------------------------------------------------- + // Public listing + // --------------------------------------------------------------------- + + /** + * List built-in themes discovered from the bundled editor's manifest. + * + * Returns an empty array if no editor is installed yet. + * + * @return array> + */ + public static function list_builtin_themes(): array { + $active = embedded_editor_source_resolver::get_active_dir(); + if ($active === null) { + return []; + } + $bundlepath = rtrim($active, '/') . '/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); + if (!is_array($data) || empty($data['themes'])) { + return []; + } + $themes = $data['themes']; + // bundle.json serializes `themes: { themes: [..] }`; accept flat too. + 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; + } + + /** + * List uploaded styles enriched with computed URL info. + * + * @return array> + */ + public static function list_uploaded_styles(): array { + $registry = self::get_registry(); + $out = []; + foreach ($registry['uploaded'] as $slug => $meta) { + if (!is_array($meta)) { + continue; + } + $meta['id'] = (string) $slug; + $meta['name'] = (string) $slug; + $meta['url'] = self::get_style_url($slug); + $meta['path'] = self::get_style_dir($slug); + $out[] = $meta; + } + return $out; + } + + /** + * Build the payload consumed by the editor's themeRegistryOverride hook. + * + * @return array + */ + public static function build_theme_registry_override(): array { + $registry = self::get_registry(); + $uploaded = []; + 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' => self::get_style_url($slug), + 'cssFiles' => $cssfiles, + 'downloadable' => '0', + 'valid' => true, + ]; + } + return [ + 'disabledBuiltins' => $registry['disabled_builtins'], + 'uploaded' => $uploaded, + 'blockImportInstall' => self::is_import_blocked(), + 'fallbackTheme' => 'base', + ]; + } + + /** + * Whether the admin has disabled user-imported styles (tab hidden, + * project-bundled styles silently ignored). Mirrors the eXeLearning + * `ONLINE_THEMES_INSTALL=false` policy. + * + * Defaults to true on first install so the editor remains locked down + * until an admin explicitly opts in. + * + * @return bool + */ + public static function is_import_blocked(): bool { + $value = get_config('exeweb', 'stylesblockimport'); + if ($value === false || $value === '' || $value === null) { + return true; + } + return (bool) $value; + } + + // --------------------------------------------------------------------- + // State changes + // --------------------------------------------------------------------- + + /** + * Toggle the enabled flag on an uploaded style. + * + * @param string $slug + * @param bool $enabled + * @return bool True on success; false if no such slug. + */ + public static function set_uploaded_enabled(string $slug, bool $enabled): bool { + $slug = self::normalize_slug($slug); + $registry = self::get_registry(); + if (!isset($registry['uploaded'][$slug])) { + return false; + } + $registry['uploaded'][$slug]['enabled'] = $enabled; + self::save_registry($registry); + return true; + } + + /** + * Toggle a built-in style (true = visible, false = hidden). + * + * @param string $id + * @param bool $enabled + */ + public static function set_builtin_enabled(string $id, bool $enabled): void { + $id = self::normalize_slug($id); + $registry = self::get_registry(); + $disabled = $registry['disabled_builtins']; + if ($enabled) { + $disabled = array_values(array_filter($disabled, static fn($d) => $d !== $id)); + } else if (!in_array($id, $disabled, true)) { + $disabled[] = $id; + } + $registry['disabled_builtins'] = $disabled; + self::save_registry($registry); + } + + /** + * Delete an uploaded style (registry entry + extracted files). + * + * @param string $slug + * @return bool True on success; false if no such slug. + */ + public static function delete_uploaded(string $slug): bool { + $slug = self::normalize_slug($slug); + $registry = self::get_registry(); + if (!isset($registry['uploaded'][$slug])) { + return false; + } + $dir = self::get_style_dir($slug); + if (is_dir($dir)) { + self::recursive_delete($dir); + } + unset($registry['uploaded'][$slug]); + self::save_registry($registry); + return true; + } + + // --------------------------------------------------------------------- + // ZIP install pipeline + // --------------------------------------------------------------------- + + /** + * Install a style from a ZIP file on disk. + * + * @param string $zippath Absolute path to the uploaded ZIP. + * @param string $origname Original filename (fallback for slug). + * @return array Registry entry. + * @throws \moodle_exception When validation or extraction fails. + */ + public static function install_from_zip(string $zippath, string $origname = ''): array { + $validation = self::validate_zip($zippath); + $config = $validation['config']; + $prefix = $validation['prefix']; + + $requestedslug = !empty($config['name']) ? $config['name'] : pathinfo($origname, PATHINFO_FILENAME); + $slug = self::allocate_unique_slug($requestedslug); + + $dest = self::get_style_dir($slug); + if (!check_dir_exists($dest, true, true)) { + throw new \moodle_exception('stylesinstallfailed', 'mod_exeweb', '', 'mkdir'); + } + + try { + self::extract_zip_safely($zippath, $dest, $prefix); + } catch (\Throwable $e) { + self::recursive_delete($dest); + throw $e; + } + + $cssfiles = self::find_css_files($dest); + if (empty($cssfiles)) { + self::recursive_delete($dest); + throw new \moodle_exception('stylesnocss', 'mod_exeweb'); + } + + $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::hash_zip($zippath), + 'size' => (int) @filesize($zippath), + ]; + + $registry = self::get_registry(); + $registry['uploaded'][$slug] = $entry; + self::save_registry($registry); + + $entry['id'] = $slug; + $entry['name'] = $slug; + return $entry; + } + + /** + * Validate an uploaded ZIP. + * + * @param string $zippath + * @return array{config: array, prefix: string} + * @throws \moodle_exception + */ + public static function validate_zip(string $zippath): array { + if (!is_file($zippath) || !is_readable($zippath)) { + throw new \moodle_exception('stylesupload_missing', 'mod_exeweb'); + } + $size = filesize($zippath); + if ($size === false || $size <= 0) { + throw new \moodle_exception('stylesupload_empty', 'mod_exeweb'); + } + if ($size > self::get_max_zip_size()) { + throw new \moodle_exception('stylesupload_toolarge', 'mod_exeweb', '', + display_size(self::get_max_zip_size())); + } + if (!class_exists('\ZipArchive')) { + throw new \moodle_exception('stylesupload_nozip', 'mod_exeweb'); + } + + $zip = new \ZipArchive(); + if ($zip->open($zippath, \ZipArchive::CHECKCONS) !== true) { + throw new \moodle_exception('stylesupload_badzip', 'mod_exeweb'); + } + + $configpath = null; + $prefix = null; + $entries = []; + + for ($i = 0; $i < $zip->numFiles; $i++) { + $stat = $zip->statIndex($i); + if ($stat === false) { + $zip->close(); + throw new \moodle_exception('stylesupload_badentry', 'mod_exeweb'); + } + $name = (string) $stat['name']; + if (self::is_unsafe_zip_entry($name)) { + $zip->close(); + throw new \moodle_exception('stylesupload_unsafe', 'mod_exeweb', '', $name); + } + $entries[] = $name; + if (basename($name) === 'config.xml') { + if ($configpath !== null) { + $zip->close(); + throw new \moodle_exception('stylesupload_multiconfig', 'mod_exeweb'); + } + $configpath = $name; + $dirname = trim(str_replace('\\', '/', dirname($name)), '/'); + $prefix = ($dirname === '' || $dirname === '.') ? '' : $dirname . '/'; + } + } + + if ($configpath === null) { + $zip->close(); + throw new \moodle_exception('stylesupload_noconfig', 'mod_exeweb'); + } + + foreach ($entries as $entry) { + if ($prefix === '') { + // When config.xml is at the root, subdirectories under root are allowed. + if (strpos($entry, '/') !== false) { + // But the extension check still applies to files. + } + } else if (strpos($entry, $prefix) !== 0) { + $zip->close(); + throw new \moodle_exception('stylesupload_mixedroots', 'mod_exeweb'); + } + if (!self::is_allowed_filename($entry)) { + $zip->close(); + throw new \moodle_exception('stylesupload_badext', 'mod_exeweb', '', $entry); + } + } + + $configxml = $zip->getFromName($configpath); + $zip->close(); + if ($configxml === false) { + throw new \moodle_exception('stylesupload_configread', 'mod_exeweb'); + } + + return [ + 'config' => self::parse_config_xml($configxml), + 'prefix' => (string) $prefix, + ]; + } + + /** + * Parse config.xml into an associative array. Throws on invalid XML or + * missing mandatory . + * + * @param string $source + * @return array + * @throws \moodle_exception + */ + public static function parse_config_xml(string $source): array { + $preverrors = libxml_use_internal_errors(true); + $xml = simplexml_load_string($source, 'SimpleXMLElement', LIBXML_NONET | LIBXML_NOENT); + libxml_clear_errors(); + libxml_use_internal_errors($preverrors); + if ($xml === false) { + throw new \moodle_exception('stylesupload_badxml', 'mod_exeweb'); + } + $name = isset($xml->name) ? trim((string) $xml->name) : ''; + if ($name === '') { + throw new \moodle_exception('stylesupload_noname', 'mod_exeweb'); + } + return [ + 'name' => self::normalize_slug($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 : '', + ]; + } + + /** + * Extract a ZIP's contents into $dest, stripping $prefix if non-empty, + * with per-entry safety checks. + * + * @param string $zippath + * @param string $dest + * @param string $prefix + * @throws \moodle_exception + */ + private static function extract_zip_safely(string $zippath, string $dest, string $prefix): void { + $zip = new \ZipArchive(); + if ($zip->open($zippath, \ZipArchive::CHECKCONS) !== true) { + throw new \moodle_exception('stylesupload_badzip', 'mod_exeweb'); + } + $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::is_unsafe_zip_entry($name)) { + $zip->close(); + throw new \moodle_exception('stylesupload_unsafe', 'mod_exeweb', '', $name); + } + $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 \moodle_exception('stylesupload_traversal', 'mod_exeweb'); + } + if (substr($name, -1) === '/') { + check_dir_exists($target, true, true); + continue; + } + check_dir_exists(dirname($target), true, true); + $contents = $zip->getFromIndex($i); + if ($contents === false) { + $zip->close(); + throw new \moodle_exception('stylesupload_readfailed', 'mod_exeweb'); + } + if (file_put_contents($target, $contents) === false) { + $zip->close(); + throw new \moodle_exception('stylesupload_writefailed', 'mod_exeweb'); + } + } + $zip->close(); + } + + // --------------------------------------------------------------------- + // Internal helpers (also exposed for tests) + // --------------------------------------------------------------------- + + /** + * Entries that must never be extracted (absolute paths, traversal, streams, empty). + * + * @param string $name + * @return bool + */ + public static function is_unsafe_zip_entry(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; + } + + /** + * Allow-list check for filenames inside the archive. + * + * @param string $name + * @return bool + */ + public static function is_allowed_filename(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); + } + + /** + * Normalize a user-supplied id into a safe slug. + * + * @param string $slug + * @return string + */ + public static function normalize_slug(string $slug): string { + $slug = strtolower(trim($slug)); + $slug = preg_replace('/[^a-z0-9-]+/', '-', $slug); + $slug = trim($slug, '-'); + return $slug === '' ? 'style' : $slug; + } + + /** + * Allocate a slug that does not collide with built-ins or existing uploads. + * + * @param string $requested + * @return string + */ + public static function allocate_unique_slug(string $requested): string { + $base = self::normalize_slug($requested); + $builtins = array_map( + static fn($t) => strtolower((string) ($t['name'] ?? '')), + self::list_builtin_themes() + ); + $registry = self::get_registry(); + $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; + } + + /** + * Scan extracted dir for CSS files. style.css first if present. + * + * @param string $dir + * @return string[] + */ + private static function find_css_files(string $dir): array { + $out = []; + if (is_file($dir . '/style.css')) { + $out[] = 'style.css'; + } + $matches = glob($dir . '/*.css'); + if (is_array($matches)) { + foreach ($matches as $file) { + $base = basename($file); + if (!in_array($base, $out, true)) { + $out[] = $base; + } + } + } + return $out; + } + + /** + * SHA-256 hash of a file, or empty string on failure. + * + * @param string $path + * @return string + */ + private static function hash_zip(string $path): string { + $hash = @hash_file('sha256', $path); + return is_string($hash) ? 'sha256:' . $hash : ''; + } + + /** + * Recursively delete a directory tree. + * + * @param string $dir + */ + public static function recursive_delete(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::recursive_delete($dir . DIRECTORY_SEPARATOR . $item); + } + @rmdir($dir); + } +} diff --git a/editor/index.php b/editor/index.php index 11fbb8a..9dec76d 100644 --- a/editor/index.php +++ b/editor/index.php @@ -153,11 +153,27 @@ function exeweb_editor_error_page(string $message): void { 'pluginVersion' => get_config('mod_exeweb', 'version'), ]); +// Approved style registry consumed by the editor's themeRegistryOverride +// hook (see exelearning/exelearning#1722). Filters built-ins, appends +// admin-uploaded styles, and blocks install-from-content paths. +$themeoverride = json_encode( + \mod_exeweb\local\styles_service::build_theme_registry_override() +); + // Inject configuration scripts before . $configscript = << window.__MOODLE_EXE_CONFIG__ = $moodleconfig; window.__EXE_EMBEDDING_CONFIG__ = $embeddingconfig; + window.eXeLearning = window.eXeLearning || {}; + window.eXeLearning.config = window.eXeLearning.config || {}; + window.eXeLearning.config.themeRegistryOverride = $themeoverride; + // Mirror blockImportInstall onto the pre-existing userStyles flag + // (ONLINE_THEMES_INSTALL) so the "install this project's style?" + // modal is also suppressed end-to-end. + window.eXeLearning.config.userStyles = + window.eXeLearning.config.themeRegistryOverride && + window.eXeLearning.config.themeRegistryOverride.blockImportInstall ? 0 : 1; EOT; diff --git a/editor/styles.php b/editor/styles.php new file mode 100644 index 0000000..802dbec --- /dev/null +++ b/editor/styles.php @@ -0,0 +1,112 @@ +. + +/** + * Serve files from an admin-uploaded eXeLearning style package. + * + * URL layout: `/mod/exeweb/editor/styles.php/{slug}/{filepath}`. + * + * Files are stored outside the plugin source tree at + * `{dataroot}/mod_exeweb/styles/{slug}/`; this endpoint is the only way + * the embedded editor reaches them. Access requires `mod/exeweb:view` + * so teachers/students/admins can load the CSS/assets the editor + * references after an admin has approved the style. + * + * @package mod_exeweb + * @copyright 2025 eXeLearning + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +// phpcs:disable moodle.Files.MoodleInternal.MoodleInternalGlobalState + +require('../../../config.php'); + +use mod_exeweb\local\styles_service; + +$pathinfo = !empty($_SERVER['PATH_INFO']) ? $_SERVER['PATH_INFO'] + : (!empty($_SERVER['ORIG_PATH_INFO']) ? $_SERVER['ORIG_PATH_INFO'] : ''); + +if (empty($pathinfo) && !empty($_SERVER['REQUEST_URI'])) { + $uri = $_SERVER['REQUEST_URI']; + $qpos = strpos($uri, '?'); + if ($qpos !== false) { + $uri = substr($uri, 0, $qpos); + } + $marker = 'styles.php/'; + $mpos = strpos($uri, $marker); + if ($mpos !== false) { + $pathinfo = '/' . substr($uri, $mpos + strlen($marker)); + } +} + +if (empty($pathinfo)) { + send_header_404(); + die('Not found'); +} + +$parts = explode('/', ltrim($pathinfo, '/'), 2); +if (count($parts) < 2 || $parts[0] === '' || $parts[1] === '') { + send_header_404(); + die('Invalid path'); +} + +$slug = clean_param($parts[0], PARAM_PATH); +$file = clean_param($parts[1], PARAM_PATH); +$file = ltrim($file, '/'); + +if (strpos($file, '..') !== false || strpos($slug, '..') !== false) { + send_header_404(); + die('File not found'); +} + +// Require a real Moodle session. The embedded editor is loaded inside an +// authenticated admin/teacher context; we still gate on mod/exeweb:view +// via the system context since admin-approved styles are site-wide. +require_login(null, false); + +$context = \context_system::instance(); +// Any user who can view at least one exeweb activity should be able to +// load the CSS. Check the module capability at system level so the +// admin preview on /mod/exeweb/admin/styles.php also works. +if (!has_capability('mod/exeweb:view', $context) + && !has_capability('mod/exeweb:manageembeddededitor', $context)) { + send_header_404(); + die('Forbidden'); +} + +// Registry gate: refuse serving slugs that were never installed. +$registry = styles_service::get_registry(); +if (!isset($registry['uploaded'][styles_service::normalize_slug($slug)])) { + send_header_404(); + die('Style not registered'); +} + +$styledir = styles_service::get_style_dir($slug); +$fullpath = realpath($styledir . '/' . $file); +$baseprefix = realpath($styledir); + +if ($baseprefix === false || $fullpath === false + || strpos($fullpath, $baseprefix . DIRECTORY_SEPARATOR) !== 0) { + send_header_404(); + die('File not found'); +} + +if (!is_file($fullpath) || !is_readable($fullpath)) { + send_header_404(); + die('File not found'); +} + +send_file($fullpath, basename($fullpath), null, 0, false, false, '', false); diff --git a/lang/en/exeweb.php b/lang/en/exeweb.php index f302f6a..48cae2d 100644 --- a/lang/en/exeweb.php +++ b/lang/en/exeweb.php @@ -256,3 +256,50 @@ $string['editoruploadmissingfile'] = 'No editor ZIP file was uploaded.'; $string['editoruploadfailed'] = 'Failed to upload the editor package: {$a}'; + +// Style management. +$string['stylesmanager'] = 'Styles'; +$string['stylesmanager_hint'] = 'Upload eXeLearning style packages and control which styles the embedded editor exposes.'; +$string['stylesmanager_intro'] = 'Manage the eXeLearning styles available to the embedded editor. Built-in styles can be hidden individually. Uploaded styles can be enabled, disabled, or deleted at any time.'; +$string['stylesonlywhenembedded'] = 'The embedded editor is not enabled. Styles managed here only take effect when the editor mode is set to "embedded".'; +$string['stylesblockimport'] = 'Block user-imported styles'; +$string['stylesblockimport_desc'] = 'When enabled, the embedded editor hides the "User styles" tab and refuses to install a style bundled inside an imported .elpx project. Users may only choose from the admin-approved list above. This mirrors the eXeLearning ONLINE_THEMES_INSTALL=false behavior.'; +$string['stylesupload_label'] = 'Style ZIP package'; +$string['stylesupload_submit'] = 'Upload style'; +$string['stylesupload_hint'] = 'Maximum file size: {$a}. Only .zip packages containing a valid config.xml are accepted.'; +$string['stylesupload_success'] = 'Style "{$a}" installed.'; +$string['stylesupload_failed'] = 'Style upload failed.'; +$string['stylesupload_missing'] = 'The uploaded file is missing or unreadable.'; +$string['stylesupload_empty'] = 'The uploaded file is empty.'; +$string['stylesupload_toolarge'] = 'The uploaded style exceeds the maximum allowed size of {$a}.'; +$string['stylesupload_nozip'] = 'The ZipArchive PHP extension is not available.'; +$string['stylesupload_badzip'] = 'The uploaded file is not a readable ZIP archive.'; +$string['stylesupload_badentry'] = 'The ZIP archive contains unreadable entries.'; +$string['stylesupload_unsafe'] = 'Rejected unsafe archive entry: {$a}'; +$string['stylesupload_multiconfig'] = 'The archive contains more than one config.xml.'; +$string['stylesupload_noconfig'] = 'The style package is missing config.xml.'; +$string['stylesupload_mixedroots'] = 'The archive must contain a single root folder or place all files at the root.'; +$string['stylesupload_badext'] = 'File type not allowed in style package: {$a}'; +$string['stylesupload_configread'] = 'config.xml could not be read from the archive.'; +$string['stylesupload_badxml'] = 'config.xml is not valid XML.'; +$string['stylesupload_noname'] = 'config.xml must declare a element.'; +$string['stylesupload_traversal'] = 'Refused path traversal during extraction.'; +$string['stylesupload_readfailed'] = 'Failed to read a file from the archive during extraction.'; +$string['stylesupload_writefailed'] = 'Failed to write an extracted file.'; +$string['stylesnocss'] = 'The uploaded style does not contain any stylesheet.'; +$string['stylesinstallfailed'] = 'Style installation failed: {$a}'; +$string['stylesuploaded'] = 'Uploaded styles'; +$string['stylesuploaded_empty'] = 'No uploaded styles yet.'; +$string['stylesbuiltin'] = 'Built-in styles'; +$string['stylesbuiltin_empty'] = 'Built-in styles are not available because the embedded editor is not installed.'; +$string['stylestable_title'] = 'Title'; +$string['stylestable_id'] = 'Id'; +$string['stylestable_version'] = 'Version'; +$string['stylestable_installed'] = 'Installed'; +$string['stylestable_enabled'] = 'Enabled'; +$string['stylestable_actions'] = 'Actions'; +$string['stylesenable'] = 'Enable'; +$string['stylesdisable'] = 'Disable'; +$string['stylesdelete'] = 'Delete'; +$string['stylesdelete_confirm'] = 'Delete this style? This cannot be undone.'; +$string['stylesdelete_success'] = 'Style deleted.'; diff --git a/settings.php b/settings.php index 4b5ce66..2eb4c85 100644 --- a/settings.php +++ b/settings.php @@ -24,6 +24,20 @@ defined('MOODLE_INTERNAL') || die; +// Register the styles management admin page so it is reachable from +// `/admin/settings.php` and from a dedicated link below. +if ($hassiteconfig) { + $ADMIN->add( + 'modsettingsexeweb', + new admin_externalpage( + 'mod_exeweb_styles', + get_string('stylesmanager', 'mod_exeweb'), + new moodle_url('/mod/exeweb/admin/styles.php'), + ['moodle/site:config', 'mod/exeweb:manageembeddededitor'] + ) + ); +} + if ($ADMIN->fulltree) { require_once("$CFG->libdir/resourcelib.php"); @@ -45,6 +59,28 @@ '' )); + // Link to the styles management page (shown only for embedded mode + // via the existing admin/editormode JS toggle below). + $styleslinkurl = new moodle_url('/mod/exeweb/admin/styles.php'); + $styleslink = ''; + $settings->add(new admin_setting_heading( + 'exeweb/stylesmanagerlink', + get_string('stylesmanager', 'mod_exeweb'), + $styleslink + )); + + $settings->add(new admin_setting_configcheckbox( + 'exeweb/stylesblockimport', + get_string('stylesblockimport', 'mod_exeweb'), + get_string('stylesblockimport_desc', 'mod_exeweb'), + 1 + )); + // Connection settings (only relevant for online mode). // Inline JS to hide/show connection settings based on editor mode selection. $connectionsettingsdesc = ' EOT; From 64bfc41f77f78573e7a21e6b0793c96630fd8919 Mon Sep 17 00:00:00 2001 From: erseco Date: Fri, 24 Apr 2026 10:19:44 +0100 Subject: [PATCH 07/13] fix(styles): admin page registration, filearea component and CSS 404 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three end-to-end bugs discovered after installing a style from the admin UI and then selecting it in the embedded editor: - Register the external styles page under the existing 'modsettings' category. It was added to 'modsettingsexeweb' which does not exist, so /admin/search.php and the plugin settings loader spammed "parent does not exist!" debug messages via admin_get_root(). - Read the style drops filearea from component 'exeweb' instead of 'mod_exeweb'. admin_setting_configstoredfile derives the component from the first segment of the setting name ('exeweb/styles_drops' → 'exeweb'), so files saved by the parent's write_setting() live under that component; our consume_pending_uploads() was walking a filearea that Moodle never writes to, silently failing to register every uploaded ZIP. - Require filelib.php before calling send_file() in the styles serve endpoint. Moodle autoloads lib/setuplib.php, but send_file lives in lib/filelib.php, so the first request to /mod/exeweb/editor/styles.php// crashed with "Call to undefined function send_file()" rendered as a themed 404 page, which the embedded editor treated as missing CSS. --- classes/admin/admin_setting_stylesupload.php | 12 ++++++++++-- editor/styles.php | 1 + settings.php | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/classes/admin/admin_setting_stylesupload.php b/classes/admin/admin_setting_stylesupload.php index 20e7fcc..2b79d85 100644 --- a/classes/admin/admin_setting_stylesupload.php +++ b/classes/admin/admin_setting_stylesupload.php @@ -42,8 +42,16 @@ */ class admin_setting_stylesupload extends \admin_setting_configstoredfile { - /** @var string Component that owns the style upload filearea. */ - public const COMPONENT = 'mod_exeweb'; + /** + * @var string Component that owns the style upload filearea. + * + * `admin_setting_configstoredfile` derives the component from the + * first segment of the setting name ('exeweb/styles_drops' → 'exeweb') + * and uses it when moving the draft into the plugin file area, so we + * must read from the same component here or the saved ZIP stays + * invisible to the registry walker. + */ + public const COMPONENT = 'exeweb'; /** @var string Filearea used to receive drops before extraction. */ public const FILEAREA = 'styles_drops'; diff --git a/editor/styles.php b/editor/styles.php index 802dbec..d3acefb 100644 --- a/editor/styles.php +++ b/editor/styles.php @@ -33,6 +33,7 @@ // phpcs:disable moodle.Files.MoodleInternal.MoodleInternalGlobalState require('../../../config.php'); +require_once($CFG->libdir . '/filelib.php'); use mod_exeweb\local\styles_service; diff --git a/settings.php b/settings.php index 126fa81..32774b8 100644 --- a/settings.php +++ b/settings.php @@ -28,7 +28,7 @@ // `/admin/settings.php` and from a dedicated link below. if ($hassiteconfig) { $ADMIN->add( - 'modsettingsexeweb', + 'modsettings', new admin_externalpage( 'mod_exeweb_styles', get_string('stylesmanager', 'mod_exeweb'), From 34f3b5cc94837619199a382c94003653f40a009c Mon Sep 17 00:00:00 2001 From: erseco Date: Fri, 24 Apr 2026 10:39:21 +0100 Subject: [PATCH 08/13] fix(styles): skip directory entries when validating style ZIPs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ZipArchive::statIndex() surfaces every explicit directory entry in the archive (e.g. 'img/', 'fonts/'), and is_allowed_filename() returns false for any trailing-slash name because it looks for a file extension. Real style packages always contain subdirectories, so uploading a valid ZIP crashed with "stylesupload_badext: img/" and the registry stayed empty. Skip directory entries before the prefix and extension checks — they are not assets, and the per-file extraction path already iterates the full archive and only writes regular files. --- classes/local/styles_service.php | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/classes/local/styles_service.php b/classes/local/styles_service.php index f07e945..55111c7 100644 --- a/classes/local/styles_service.php +++ b/classes/local/styles_service.php @@ -453,12 +453,14 @@ public static function validate_zip(string $zippath): array { } foreach ($entries as $entry) { - if ($prefix === '') { - // When config.xml is at the root, subdirectories under root are allowed. - if (strpos($entry, '/') !== false) { - // But the extension check still applies to files. - } - } else if (strpos($entry, $prefix) !== 0) { + // ZipArchive::statIndex() surfaces every explicit directory + // entry (e.g. 'img/', 'fonts/'). Skip them before any + // extension/prefix checks — a directory is not an asset and + // is_allowed_filename() rejects trailing-slash names. + if (substr($entry, -1) === '/') { + continue; + } + if ($prefix !== '' && strpos($entry, $prefix) !== 0) { $zip->close(); throw new \moodle_exception('stylesupload_mixedroots', 'mod_exeweb'); } From ec39178707812b248b02e0ee27a2ebde68ec368a Mon Sep 17 00:00:00 2001 From: erseco Date: Tue, 28 Apr 2026 11:36:01 +0100 Subject: [PATCH 09/13] fix(styles): publish admin-uploaded icon set in registry override The editor's icon picker reads `icons` from the theme payload that the host integration emits via `themeRegistryOverride`. Built-in themes get this list synthesized by the editor itself (theme-parser.ts::scanThemeIcons walks the `icons/` folder), but admin-uploaded styles arrive prebuilt and were missing the field, so the picker fell back to the empty default and showed nothing. Mirror the upstream scan logic in PHP: walk `/icons/`, accept png/svg/gif/jpg/jpeg, build `[id => {id, title, type:img, value:url}]`, and include it on every uploaded entry. URLs are served by the existing editor/styles.php endpoint. --- classes/local/styles_service.php | 50 +++++++++++++++++++++++++- tests/local/styles_service_test.php | 54 +++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/classes/local/styles_service.php b/classes/local/styles_service.php index 55111c7..f5ee287 100644 --- a/classes/local/styles_service.php +++ b/classes/local/styles_service.php @@ -225,6 +225,7 @@ public static function build_theme_registry_override(): array { ? array_values(array_map('strval', $meta['css_files'])) : ['style.css']; $files = self::list_uploaded_files($slug); + $styleurl = self::get_style_url($slug); $uploaded[] = [ 'id' => (string) $slug, 'name' => (string) $slug, @@ -235,9 +236,10 @@ public static function build_theme_registry_override(): array { 'author' => (string) ($meta['author'] ?? ''), 'license' => (string) ($meta['license'] ?? ''), 'type' => 'admin', - 'url' => self::get_style_url($slug), + 'url' => $styleurl, 'cssFiles' => $cssfiles, 'files' => $files, + 'icons' => self::scan_uploaded_icons($slug, $styleurl), 'downloadable' => '0', 'valid' => true, ]; @@ -657,6 +659,52 @@ public static function list_uploaded_files(string $slug): array { 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). + * + * @param string $slug Uploaded style slug (already validated upstream). + * @param string $styleurl Absolute URL prefix at which the style is served. + * @return array + */ + public static function scan_uploaded_icons(string $slug, string $styleurl): array { + $slug = self::normalize_slug($slug); + $dir = self::get_style_dir($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; + } + /** * Normalize a user-supplied id into a safe slug. * diff --git a/tests/local/styles_service_test.php b/tests/local/styles_service_test.php index 9b8ac57..ee35461 100644 --- a/tests/local/styles_service_test.php +++ b/tests/local/styles_service_test.php @@ -171,6 +171,10 @@ public function test_build_theme_registry_override_respects_enabled_flag(): void $this->assertSame('base', $override['fallbackTheme']); $this->assertCount(1, $override['uploaded']); $this->assertSame('seen', $override['uploaded'][0]['id']); + // 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']); styles_service::set_uploaded_enabled('seen', false); $override = styles_service::build_theme_registry_override(); @@ -178,6 +182,56 @@ public function test_build_theme_registry_override_respects_enabled_flag(): void @unlink($zip); } + public function test_build_theme_registry_override_publishes_icons_from_icons_folder(): void { + $zip = $this->make_zip([ + 'config.xml' => $this->sample_config_xml('iconic'), + 'style.css' => 'a{}', + 'icons/activity.png' => 'PNG', + 'icons/alert.svg' => '', + 'icons/photo.JPG' => 'JPEG', + 'icons/readme.txt' => 'ignore', + 'icons/no-extension' => 'ignore', + ]); + styles_service::install_from_zip($zip); + + $override = styles_service::build_theme_registry_override(); + $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 test_scan_uploaded_icons_returns_empty_when_icons_folder_missing(): void { + $this->assertSame( + [], + styles_service::scan_uploaded_icons('no-such-style', 'http://example.test/styles/no-such-style') + ); + } + + public function test_scan_uploaded_icons_url_encodes_filenames_with_spaces(): void { + $zip = $this->make_zip([ + 'config.xml' => $this->sample_config_xml('spaced'), + 'style.css' => 'a{}', + 'icons/my activity.png' => 'PNG', + ]); + styles_service::install_from_zip($zip); + + $icons = styles_service::scan_uploaded_icons('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 test_import_blocked_follows_admin_config(): void { $this->assertTrue(styles_service::is_import_blocked()); // Default locked down. From 6d2bf0bc8d51a4cb3a90c7e712da3a237c37579f Mon Sep 17 00:00:00 2001 From: erseco Date: Tue, 28 Apr 2026 17:09:45 +0100 Subject: [PATCH 10/13] fix(styles): allow saving admin settings without re-uploading a style The styles upload setting delegated to admin_setting_configstoredfile, which persists the submitted file path in plugin config and trips its errorsetting validation when the form is saved a second time without changes -- by then consume_pending_uploads() has already extracted and removed the ZIP, so the cached config points at a file that no longer exists. Bypass the parent and stage drafts directly via file_save_draft_area_files(), returning '' regardless of whether a new ZIP was attached. Built-in styles continue to default to enabled because disabled_builtins remains empty on first install. --- classes/admin/admin_setting_stylesupload.php | 43 +++-- .../admin/admin_setting_stylesupload_test.php | 149 ++++++++++++++++++ 2 files changed, 180 insertions(+), 12 deletions(-) create mode 100644 tests/admin/admin_setting_stylesupload_test.php diff --git a/classes/admin/admin_setting_stylesupload.php b/classes/admin/admin_setting_stylesupload.php index 2b79d85..559bc35 100644 --- a/classes/admin/admin_setting_stylesupload.php +++ b/classes/admin/admin_setting_stylesupload.php @@ -31,14 +31,14 @@ /** * Inline filemanager setting for the Styles admin section. * - * Reuses Moodle's native `admin_setting_configstoredfile` — the exact - * same pattern as the 'Default template' filemanager already present in - * the plugin settings. After Moodle moves the draft files into the - * plugin's file area on save, we walk them through - * {@see styles_service::install_from_zip()}: successful installs are - * removed from the file area (we only care about the extracted output); - * failed ones are kept so the admin can spot them and a notification is - * queued with the specific error. + * Reuses Moodle's native `admin_setting_configstoredfile` only for its + * filemanager rendering. Persistence is handled here because our pipeline + * is fire-and-forget: dropped ZIPs are extracted into moodledata by + * {@see styles_service::install_from_zip()} and then deleted from the + * filearea, so the parent's "remember the last filepath in config" flow + * would either be wrong (config points at a file we already removed) or + * trip the parent's `errorsetting` validation when the page is resaved + * without changes. */ class admin_setting_stylesupload extends \admin_setting_configstoredfile { @@ -57,13 +57,32 @@ class admin_setting_stylesupload extends \admin_setting_configstoredfile { public const FILEAREA = 'styles_drops'; /** - * Persist the uploaded files and extract any fresh ZIPs. + * Stage any uploaded drafts into the plugin filearea and extract them. + * + * We bypass `admin_setting_configstoredfile::write_setting()` because + * that path persists the submitted file's path in plugin config and + * trips an `errorsetting` validation when the form is saved a second + * time without changes — by then the cached config still points at a + * file `consume_pending_uploads()` already extracted and removed. + * Since we never read the config value back, just move the drafts and + * return success regardless of whether anything was attached. * * @param string $data The draft item id. - * @return string Empty on success or error message string. + * @return string Always empty (success); install errors surface as notifications. */ public function write_setting($data) { - $return = parent::write_setting($data); + if (is_numeric($data) && (int) $data > 0) { + $options = $this->get_options(); + $component = is_null($this->plugin) ? 'core' : $this->plugin; + file_save_draft_area_files( + $data, + $options['context']->id, + $component, + $this->filearea, + $this->itemid, + $options + ); + } $summary = $this->consume_pending_uploads(); foreach ($summary['installed'] as $title) { \core\notification::success( @@ -73,7 +92,7 @@ public function write_setting($data) { foreach ($summary['errors'] as $error) { \core\notification::error($error); } - return $return; + return ''; } /** diff --git a/tests/admin/admin_setting_stylesupload_test.php b/tests/admin/admin_setting_stylesupload_test.php new file mode 100644 index 0000000..dba22f4 --- /dev/null +++ b/tests/admin/admin_setting_stylesupload_test.php @@ -0,0 +1,149 @@ +. + +/** + * Tests for the styles upload admin setting. + * + * @package mod_exeweb + * @copyright 2025 eXeLearning + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_exeweb\admin; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->libdir . '/adminlib.php'); +require_once($CFG->libdir . '/filelib.php'); + +/** + * Regression tests covering the save flow of admin_setting_stylesupload. + * + * @covers \mod_exeweb\admin\admin_setting_stylesupload + */ +class admin_setting_stylesupload_test extends \advanced_testcase { + + protected function setUp(): void { + parent::setUp(); + $this->resetAfterTest(true); + $this->setAdminUser(); + } + + /** + * Build the setting under test with the same options the plugin uses + * in its settings page. + * + * @return admin_setting_stylesupload + */ + private function build_setting(): admin_setting_stylesupload { + return new admin_setting_stylesupload( + 'exeweb/styles_drops', + 'Style ZIP package', + 'hint', + 'styles_drops', + 0, + [ + 'accepted_types' => ['.zip'], + 'maxbytes' => 0, + 'maxfiles' => -1, + 'subdirs' => 0, + ] + ); + } + + /** + * Saving the settings page without dropping a new ZIP must succeed. + * Regression test for the "Could not save setting" error users hit + * after their first successful upload — the cached config from the + * parent class made `admin_setting_configstoredfile::write_setting` + * trip its `errorsetting` validation when called again with no file. + */ + public function test_write_setting_with_no_draft_returns_empty(): void { + $setting = $this->build_setting(); + $this->assertSame('', $setting->write_setting('')); + $this->assertSame('', $setting->write_setting('0')); + $this->assertSame('', $setting->write_setting(null)); + } + + /** + * Saving with a fresh draft id but no files in it (the form was opened + * but no ZIP was attached) must still succeed. + */ + public function test_write_setting_with_empty_draft_returns_empty(): void { + $setting = $this->build_setting(); + $draftitemid = file_get_unused_draft_itemid(); + $this->assertSame('', $setting->write_setting((string) $draftitemid)); + } + + /** + * After a successful install the filearea is purged. A subsequent save + * with no new file must also succeed — this is the actual production + * scenario the user reported. + */ + public function test_write_setting_after_install_then_empty_save(): void { + global $USER; + $setting = $this->build_setting(); + + // Stage a valid style zip in a fresh draft area. + $draftitemid = file_get_unused_draft_itemid(); + $usercontext = \context_user::instance($USER->id); + $fs = get_file_storage(); + $fs->create_file_from_string([ + 'contextid' => $usercontext->id, + 'component' => 'user', + 'filearea' => 'draft', + 'itemid' => $draftitemid, + 'filepath' => '/', + 'filename' => 'mystyle.zip', + ], $this->make_style_zip_contents('mystyle')); + + // First save: the ZIP is consumed, extracted, and the filearea + // is left empty by the service. + $this->assertSame('', $setting->write_setting((string) $draftitemid)); + $registry = \mod_exeweb\local\styles_service::get_registry(); + $this->assertArrayHasKey('mystyle', $registry['uploaded']); + + // Second save with nothing attached must not return an error. + $this->assertSame('', $setting->write_setting('')); + $this->assertSame('', $setting->write_setting((string) file_get_unused_draft_itemid())); + } + + /** + * Build the bytes of a minimal valid style ZIP (config.xml + style.css). + * + * @param string $name Theme identifier baked into config.xml. + * @return string ZIP bytes. + */ + private function make_style_zip_contents(string $name): string { + $tmp = tempnam(sys_get_temp_dir(), 'exewebstyle') . '.zip'; + @unlink($tmp); + $zip = new \ZipArchive(); + $this->assertTrue($zip->open($tmp, \ZipArchive::CREATE) === true); + $zip->addFromString('config.xml', + '' + . '' . htmlspecialchars($name) . '' + . '' . htmlspecialchars(ucfirst($name)) . '' + . '1.0.0' + . '' + ); + $zip->addFromString('style.css', 'body{color:#000}'); + $zip->close(); + $contents = file_get_contents($tmp); + @unlink($tmp); + return $contents; + } +} From a4c886f1da18ed1500a4b486af21954e420a226b Mon Sep 17 00:00:00 2001 From: erseco Date: Tue, 28 Apr 2026 17:41:48 +0100 Subject: [PATCH 11/13] fix(editor): swallow CSS / iDevice 404s and fix preview-sw registration The static editor's ResourceFetcher rejects on missing CSS / iDevice resources, which surfaces as an "Uncaught (in promise)" that aborts the Yjs theme bind and leaves the editor unresponsive when one of those assets is unreachable (notably under moodle-playground, where static.php cannot resolve every theme path embedded in bundle.json). Port the WP / Omeka-S workaround: wrap window.fetch and jQuery's $.ajaxTransport so 404s on .css / idevices URLs return an empty stylesheet, and short-circuit the editor's preview-sw.js registration so the registration error stops spamming the console without blocking anything. Brings mod_exeweb in line with the other host integrations. --- editor/index.php | 102 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/editor/index.php b/editor/index.php index a7b7d74..4bd6a0e 100644 --- a/editor/index.php +++ b/editor/index.php @@ -210,6 +210,108 @@ enumerable: true, trapConfig(window.eXeLearning); } })(); + + // The static editor's ResourceFetcher rejects on missing CSS / iDevice + // resources, which surfaces as an "Uncaught (in promise)" that aborts + // the Yjs theme bind and leaves the editor unresponsive. WP and Omeka-S + // ship the same workaround: swallow 404s on .css / idevices URLs and + // return an empty stylesheet so the editor keeps booting. + // Disable any new service-worker registration (the static editor's + // preview-sw.js is served from the same static.php router; environments + // that proxy or cache that router — e.g. moodle-playground — return a + // 404 there and the registration error spams the console without + // blocking anything). + (function() { + if ("serviceWorker" in navigator) { + try { + var registerOriginal = navigator.serviceWorker.register + ? navigator.serviceWorker.register.bind(navigator.serviceWorker) + : null; + navigator.serviceWorker.register = function(scriptURL, options) { + if (typeof scriptURL === "string" && scriptURL.indexOf("preview-sw.js") !== -1) { + return Promise.resolve({ scope: "" }); + } + return registerOriginal + ? registerOriginal(scriptURL, options) + : Promise.resolve({ scope: "" }); + }; + } catch (e) { + // Some embeds make navigator.serviceWorker non-writable; ignore. + } + } + + var originalFetch = window.fetch; + if (originalFetch) { + window.fetch = function(input, init) { + var url = typeof input === "string" ? input : (input && input.url) || ""; + return originalFetch.apply(this, arguments).then(function(response) { + if (!response.ok && (url.indexOf(".css") !== -1 || url.indexOf("idevices") !== -1)) { + console.warn("[mod_exeweb] Fetch 404 fallback:", url); + return new Response("/* empty fallback */", { + status: 200, + headers: { "Content-Type": "text/css" } + }); + } + return response; + }).catch(function(error) { + if (url.indexOf(".css") !== -1 || url.indexOf("idevices") !== -1) { + console.warn("[mod_exeweb] Fetch error fallback:", url); + return new Response("/* empty fallback */", { + status: 200, + headers: { "Content-Type": "text/css" } + }); + } + throw error; + }); + }; + } + + var patchJQuery = function(\$) { + if (!\$ || !\$.ajaxTransport) return; + \$.ajaxTransport("+*", function(options) { + var url = options.url || ""; + if (!(url.indexOf(".css") !== -1 || url.indexOf("idevices") !== -1)) return; + return { + send: function(headers, completeCallback) { + var xhr = new XMLHttpRequest(); + xhr.open(options.type || "GET", url, true); + xhr.onload = function() { + if (xhr.status >= 200 && xhr.status < 300) { + completeCallback(xhr.status, xhr.statusText, { text: xhr.responseText }); + } else { + console.warn("[mod_exeweb] jQuery 404 fallback:", url); + completeCallback(200, "OK", { text: "/* empty fallback */" }); + } + }; + xhr.onerror = function() { + console.warn("[mod_exeweb] jQuery error fallback:", url); + completeCallback(200, "OK", { text: "/* empty fallback */" }); + }; + xhr.send(); + }, + abort: function() {} + }; + }); + }; + if (window.jQuery) { + patchJQuery(window.jQuery); + } else { + try { + Object.defineProperty(window, "jQuery", { + configurable: true, + set: function(val) { + Object.defineProperty(window, "jQuery", { + configurable: true, writable: true, enumerable: true, value: val + }); + patchJQuery(val); + }, + get: function() { return undefined; } + }); + } catch (e) { + // jQuery already defined non-configurable; nothing to patch. + } + } + })(); EOT; From 3e2a1b11b4aa4ddd88b46af7814f29c81a7a4b2d Mon Sep 17 00:00:00 2001 From: ignaciogros Date: Wed, 29 Apr 2026 09:31:04 +0200 Subject: [PATCH 12/13] v4.0.0 changelog revision (release date). --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d724940..aafec8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # CHANGELOG -## v4.0.0 – 2025-04-28 +## v4.0.0 – 2025-04-29 - Version jump to 4.0.0 to align numbering with eXeLearning for consistency across related projects. - Introduce fully integrated embedded eXeLearning editor inside Moodle, enabling content creation and editing without leaving the platform. From eff52b0545d26335c7ca9eb04c3f46dfebdef232 Mon Sep 17 00:00:00 2001 From: erseco Date: Wed, 29 Apr 2026 10:57:58 +0100 Subject: [PATCH 13/13] fix(styles): persist disable when all checkboxes cleared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit admin_find_write_settings() only routes a setting through write_setting() when its parent name appears in $_POST. With every checkbox in the styles_uploaded / styles_builtins widgets cleared the browser submits nothing under that name, so the registry never learned the user disabled the last enabled entry — unchecking the only uploaded style and saving left it enabled. Mirror core's admin_setting_configmulticheckbox template by emitting a hidden sentinel input next to the list so the parent name is always present and write_setting() runs even when nothing is checked. --- .../admin/admin_setting_stylesbuiltins.php | 9 +- .../admin/admin_setting_stylesuploaded.php | 9 +- .../admin_setting_stylesuploaded_test.php | 130 ++++++++++++++++++ 3 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 tests/admin/admin_setting_stylesuploaded_test.php diff --git a/classes/admin/admin_setting_stylesbuiltins.php b/classes/admin/admin_setting_stylesbuiltins.php index b898b5a..600868a 100644 --- a/classes/admin/admin_setting_stylesbuiltins.php +++ b/classes/admin/admin_setting_stylesbuiltins.php @@ -117,7 +117,14 @@ public function output_html($data, $query = '') { . '' . ''; } - $html = '