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.
diff --git a/admin/styles.php b/admin/styles.php
new file mode 100644
index 0000000..7c53ad3
--- /dev/null
+++ b/admin/styles.php
@@ -0,0 +1,183 @@
+.
+
+/**
+ * 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');
+$settingsurl = new moodle_url('/admin/settings.php', ['section' => 'modsettingexeweb']);
+
+// --------------------------------------------------------------------
+// Toggle/delete actions use GET + sesskey (simple URL handlers).
+// Upload happens inline in the plugin settings page.
+// --------------------------------------------------------------------
+if ($action !== '') {
+ require_sesskey();
+ switch ($action) {
+ 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'));
+
+// Point admins at the inline uploader on the plugin settings page —
+// the filemanager there is the single entry point for uploading styles.
+echo html_writer::tag('p',
+ html_writer::link(
+ $settingsurl,
+ get_string('stylesupload_goto_settings', 'mod_exeweb'),
+ ['class' => 'btn btn-secondary']
+ )
+);
+
+// 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/admin/admin_setting_stylesbuiltins.php b/classes/admin/admin_setting_stylesbuiltins.php
new file mode 100644
index 0000000..600868a
--- /dev/null
+++ b/classes/admin/admin_setting_stylesbuiltins.php
@@ -0,0 +1,131 @@
+.
+
+/**
+ * Admin setting that renders built-in styles as a list of checkboxes.
+ *
+ * @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();
+
+use mod_exeweb\local\styles_service;
+
+/**
+ * Per-built-in style enable/disable checkboxes, inline in the plugin
+ * settings page. Source of truth stays in the styles registry
+ * (`config_plugin(exeweb).styles_registry`), so the widget reads state
+ * from it at render time and writes back through the service.
+ */
+class admin_setting_stylesbuiltins extends \admin_setting {
+
+ /**
+ * @param string $name Setting key (used for the HTML input name).
+ */
+ public function __construct(string $name) {
+ parent::__construct(
+ $name,
+ get_string('stylesbuiltin', 'mod_exeweb'),
+ get_string('stylesbuiltin_hint', 'mod_exeweb'),
+ []
+ );
+ }
+
+ /**
+ * This setting does not live in $CFG; the registry owns the state.
+ *
+ * @return array
+ */
+ public function get_setting() {
+ return [];
+ }
+
+ /**
+ * @return array
+ */
+ public function get_defaultsetting() {
+ return [];
+ }
+
+ /**
+ * Persist checkbox state back into the registry.
+ *
+ * @param array $data Posted value map (slug => '1' when checked).
+ * @return string Empty on success.
+ */
+ public function write_setting($data) {
+ if (!is_array($data)) {
+ $data = [];
+ }
+ foreach (styles_service::list_builtin_themes() as $theme) {
+ $id = $theme['id'];
+ $enabled = !empty($data[$id]);
+ styles_service::set_builtin_enabled($id, $enabled);
+ }
+ return '';
+ }
+
+ /**
+ * Render the checkbox list.
+ *
+ * @param mixed $data Unused — current state comes from the registry.
+ * @param string $query Current admin search query.
+ * @return string
+ */
+ public function output_html($data, $query = '') {
+ $registry = styles_service::get_registry();
+ $disabled = $registry['disabled_builtins'];
+ $builtins = styles_service::list_builtin_themes();
+ if (empty($builtins)) {
+ $html = \html_writer::tag('p',
+ get_string('stylesbuiltin_empty', 'mod_exeweb'),
+ ['class' => 'text-muted']
+ );
+ return format_admin_setting($this, $this->visiblename, $html, $this->description);
+ }
+ $rows = '';
+ foreach ($builtins as $theme) {
+ $id = $theme['id'];
+ $checked = in_array($id, $disabled, true) ? '' : 'checked';
+ $inputname = $this->get_full_name() . '[' . $id . ']';
+ $version = !empty($theme['version'])
+ ? ' v' . s($theme['version']) . ''
+ : '';
+ $rows .= '
'
+ . ''
+ . '
';
+ }
+ // Hidden sentinel so the form always posts the parent name. Without
+ // it admin_find_write_settings() skips write_setting() when every
+ // checkbox is cleared, so disabling all builtins from the form
+ // would leave them silently re-enabled.
+ $sentinel = '';
+ $html = $sentinel
+ . '
' . $rows . '
';
+ return format_admin_setting($this, $this->visiblename, $html, $this->description);
+ }
+}
diff --git a/classes/admin/admin_setting_stylesupload.php b/classes/admin/admin_setting_stylesupload.php
new file mode 100644
index 0000000..559bc35
--- /dev/null
+++ b/classes/admin/admin_setting_stylesupload.php
@@ -0,0 +1,134 @@
+.
+
+/**
+ * Admin setting that accepts style ZIP uploads and auto-installs them.
+ *
+ * @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();
+
+use mod_exeweb\local\styles_service;
+
+/**
+ * Inline filemanager setting for the Styles admin section.
+ *
+ * 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 {
+
+ /**
+ * @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';
+
+ /**
+ * 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 Always empty (success); install errors surface as notifications.
+ */
+ public function 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(
+ get_string('stylesupload_success', 'mod_exeweb', $title)
+ );
+ }
+ foreach ($summary['errors'] as $error) {
+ \core\notification::error($error);
+ }
+ return '';
+ }
+
+ /**
+ * Walk the filearea, install each ZIP, and drop files we successfully
+ * consumed so the next render starts clean.
+ *
+ * @return array{installed: string[], errors: string[]}
+ */
+ protected function consume_pending_uploads(): array {
+ $summary = ['installed' => [], 'errors' => []];
+ $fs = get_file_storage();
+ $context = \context_system::instance();
+ $files = $fs->get_area_files(
+ $context->id, self::COMPONENT, self::FILEAREA,
+ 0, 'sortorder, id', false
+ );
+ if (empty($files)) {
+ return $summary;
+ }
+ $tmpdir = make_request_directory();
+ foreach ($files as $file) {
+ $filename = $file->get_filename();
+ $tmppath = $tmpdir . '/' . clean_param($filename, PARAM_FILE);
+ try {
+ $file->copy_content_to($tmppath);
+ $entry = styles_service::install_from_zip($tmppath, $filename);
+ $summary['installed'][] = $entry['title'] ?? $entry['name'];
+ $file->delete();
+ } catch (\Throwable $e) {
+ $summary['errors'][] = $filename . ': ' . $e->getMessage();
+ } finally {
+ if (is_file($tmppath)) {
+ @unlink($tmppath);
+ }
+ }
+ }
+ return $summary;
+ }
+}
diff --git a/classes/admin/admin_setting_stylesuploaded.php b/classes/admin/admin_setting_stylesuploaded.php
new file mode 100644
index 0000000..b06c3a7
--- /dev/null
+++ b/classes/admin/admin_setting_stylesuploaded.php
@@ -0,0 +1,125 @@
+.
+
+/**
+ * Admin setting that renders uploaded styles as enable/disable checkboxes
+ * and per-row delete links.
+ *
+ * @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();
+
+use mod_exeweb\local\styles_service;
+
+/**
+ * Per-uploaded-style enable/disable checkboxes + delete buttons.
+ *
+ * Follows the same source-of-truth-is-the-registry pattern as
+ * {@see admin_setting_stylesbuiltins}. Deletes are handled via a
+ * separate redirect-only endpoint so they survive nested-form
+ * constraints and preserve sesskey validation.
+ */
+class admin_setting_stylesuploaded extends \admin_setting {
+
+ public function __construct(string $name) {
+ parent::__construct(
+ $name,
+ get_string('stylesuploaded', 'mod_exeweb'),
+ get_string('stylesuploaded_hint', 'mod_exeweb'),
+ []
+ );
+ }
+
+ public function get_setting() {
+ return [];
+ }
+
+ public function get_defaultsetting() {
+ return [];
+ }
+
+ public function write_setting($data) {
+ if (!is_array($data)) {
+ $data = [];
+ }
+ $registry = styles_service::get_registry();
+ foreach (array_keys($registry['uploaded']) as $slug) {
+ $enabled = !empty($data[$slug]);
+ styles_service::set_uploaded_enabled($slug, $enabled);
+ }
+ return '';
+ }
+
+ public function output_html($data, $query = '') {
+ $uploaded = styles_service::list_uploaded_styles();
+ if (empty($uploaded)) {
+ $html = \html_writer::tag('p',
+ get_string('stylesuploaded_empty', 'mod_exeweb'),
+ ['class' => 'text-muted']
+ );
+ return format_admin_setting($this, $this->visiblename, $html, $this->description);
+ }
+ $rows = '';
+ foreach ($uploaded as $style) {
+ $slug = $style['id'];
+ $checked = !empty($style['enabled']) ? 'checked' : '';
+ $inputname = $this->get_full_name() . '[' . $slug . ']';
+ $version = !empty($style['version'])
+ ? ' v' . s($style['version']) . ''
+ : '';
+ $deleteurl = new \moodle_url('/mod/exeweb/admin/styles.php', [
+ 'action' => 'delete',
+ 'slug' => $slug,
+ 'sesskey' => sesskey(),
+ ]);
+ $deletelink = \html_writer::link(
+ $deleteurl,
+ get_string('stylesdelete', 'mod_exeweb'),
+ [
+ 'class' => 'btn btn-link text-danger p-0 ml-2',
+ 'style' => 'margin-left:.75em;',
+ 'onclick' => "return confirm('"
+ . addslashes_js(get_string('stylesdelete_confirm', 'mod_exeweb'))
+ . "');",
+ ]
+ );
+ $rows .= '
'
+ . ''
+ . $deletelink
+ . '
';
+ }
+ // Hidden sentinel so the form always posts the parent name. Without
+ // it admin_find_write_settings() skips write_setting() when every
+ // checkbox is cleared, so the registry never learns the user
+ // disabled the last enabled upload.
+ $sentinel = '';
+ $html = $sentinel
+ . '
' . $rows . '
';
+ return format_admin_setting($this, $this->visiblename, $html, $this->description);
+ }
+}
diff --git a/classes/local/styles_service.php b/classes/local/styles_service.php
new file mode 100644
index 0000000..f5ee287
--- /dev/null
+++ b/classes/local/styles_service.php
@@ -0,0 +1,804 @@
+.
+
+/**
+ * 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'];
+ $files = self::list_uploaded_files($slug);
+ $styleurl = self::get_style_url($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' => self::scan_uploaded_icons($slug, $styleurl),
+ '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 {
+ // Default: imports allowed (matches upstream ONLINE_THEMES_INSTALL=true).
+ // Admins enable the lockdown explicitly from the Styles page.
+ $value = get_config('exeweb', 'stylesblockimport');
+ if ($value === false || $value === '' || $value === null) {
+ return false;
+ }
+ 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) {
+ // 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');
+ }
+ 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);
+ }
+
+ /**
+ * 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.
+ *
+ * @param string $slug
+ * @return string[]
+ */
+ public static function list_uploaded_files(string $slug): array {
+ $slug = self::normalize_slug($slug);
+ $dir = self::get_storage_dir() . '/' . $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).
+ *
+ * @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.
+ *
+ * @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..4bd6a0e 100644
--- a/editor/index.php
+++ b/editor/index.php
@@ -153,11 +153,165 @@ 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 .
+// The static editor boot sequence reassigns window.eXeLearning and
+// window.eXeLearning.config repeatedly (the inline script in index.html
+// resets the whole object, and app.bundle.js later parses 'config' from a
+// JSON string back into an object), so a plain assignment of
+// themeRegistryOverride never reaches the editor. Wrap the injection in a
+// self-restoring defineProperty getter/setter so the override and the
+// userStyles mirror survive every reset.
$configscript = <<
window.__MOODLE_EXE_CONFIG__ = $moodleconfig;
window.__EXE_EMBEDDING_CONFIG__ = $embeddingconfig;
+ (function() {
+ var OVERRIDE = $themeoverride;
+ function injectConfig(cfg) {
+ if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) return cfg;
+ cfg.themeRegistryOverride = OVERRIDE;
+ // Mirror blockImportInstall onto the pre-existing userStyles
+ // flag (ONLINE_THEMES_INSTALL) so the install-from-project
+ // modal is also suppressed end-to-end.
+ cfg.userStyles = OVERRIDE && OVERRIDE.blockImportInstall ? 0 : 1;
+ return cfg;
+ }
+ function trapConfig(target) {
+ if (!target || typeof target !== "object") return;
+ var stored = injectConfig(target.config);
+ try {
+ Object.defineProperty(target, "config", {
+ configurable: true,
+ enumerable: true,
+ get: function() { return stored; },
+ set: function(v) { stored = injectConfig(v); }
+ });
+ } catch (e) {
+ target.config = stored;
+ }
+ }
+ var rootValue = window.eXeLearning;
+ trapConfig(rootValue);
+ try {
+ Object.defineProperty(window, "eXeLearning", {
+ configurable: true,
+ get: function() { return rootValue; },
+ set: function(v) { rootValue = v; trapConfig(v); }
+ });
+ } catch (e) {
+ window.eXeLearning = rootValue || {};
+ 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;
diff --git a/editor/styles.php b/editor/styles.php
new file mode 100644
index 0000000..d3acefb
--- /dev/null
+++ b/editor/styles.php
@@ -0,0 +1,113 @@
+.
+
+/**
+ * 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');
+require_once($CFG->libdir . '/filelib.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 11d150a..a963a02 100644
--- a/lang/en/exeweb.php
+++ b/lang/en/exeweb.php
@@ -256,3 +256,56 @@
$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['stylesmanager_manage'] = 'Manage installed styles';
+$string['stylesmanager_manage_hint'] = 'Open the styles page to enable/disable built-in styles or delete uploaded ones.';
+$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_success_many'] = 'Installed: {$a}';
+$string['stylesupload_goto_settings'] = 'Upload styles from the plugin settings page';
+$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['stylesuploaded_hint'] = 'Enable or disable uploaded styles. Uncheck to hide a style from the editor; delete to remove it permanently.';
+$string['stylesbuiltin'] = 'Built-in styles';
+$string['stylesbuiltin_empty'] = 'Built-in styles are not available because the embedded editor is not installed.';
+$string['stylesbuiltin_hint'] = 'Uncheck a style to hide it from the editor. Disabled built-ins are not deleted; the project can always fall back to the default style.';
+$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/lang/es/exeweb.php b/lang/es/exeweb.php
index 8fd8e4c..9673290 100644
--- a/lang/es/exeweb.php
+++ b/lang/es/exeweb.php
@@ -254,3 +254,56 @@
$string['editoruploadmissingfile'] = 'No se ha subido ningún archivo ZIP del editor.';
$string['editoruploadfailed'] = 'No se pudo subir el paquete del editor: {$a}';
+
+// Gestión de estilos.
+$string['stylesmanager'] = 'Estilos';
+$string['stylesmanager_hint'] = 'Sube paquetes de estilos de eXeLearning y controla qué estilos expone el editor integrado.';
+$string['stylesmanager_intro'] = 'Gestiona los estilos de eXeLearning disponibles para el editor integrado. Los estilos integrados se pueden ocultar de forma individual. Los estilos subidos se pueden habilitar, deshabilitar o eliminar en cualquier momento.';
+$string['stylesmanager_manage'] = 'Gestionar estilos instalados';
+$string['stylesmanager_manage_hint'] = 'Abre la página de estilos para habilitar o deshabilitar los estilos integrados, o para eliminar estilos subidos.';
+$string['stylesonlywhenembedded'] = 'El editor integrado no está activado. Los estilos gestionados aquí sólo se aplican cuando el modo del editor es «integrado».';
+$string['stylesblockimport'] = 'Bloquear estilos importados por el usuario';
+$string['stylesblockimport_desc'] = 'Cuando está activado, el editor integrado oculta la pestaña «Estilos importados» y rechaza instalar un estilo incluido en un proyecto .elpx importado. El usuario sólo podrá elegir entre la lista aprobada por el administrador. Equivale al comportamiento de eXeLearning ONLINE_THEMES_INSTALL=false.';
+$string['stylesupload_label'] = 'Paquete ZIP de estilo';
+$string['stylesupload_submit'] = 'Subir estilo';
+$string['stylesupload_hint'] = 'Tamaño máximo: {$a}. Solo se aceptan paquetes .zip con un config.xml válido.';
+$string['stylesupload_success'] = 'Estilo «{$a}» instalado.';
+$string['stylesupload_success_many'] = 'Instalados: {$a}';
+$string['stylesupload_goto_settings'] = 'Subir estilos desde la página de configuración del plugin';
+$string['stylesupload_failed'] = 'La subida del estilo ha fallado.';
+$string['stylesupload_missing'] = 'El archivo subido no existe o no se puede leer.';
+$string['stylesupload_empty'] = 'El archivo subido está vacío.';
+$string['stylesupload_toolarge'] = 'El estilo subido supera el tamaño máximo permitido de {$a}.';
+$string['stylesupload_nozip'] = 'La extensión PHP ZipArchive no está disponible.';
+$string['stylesupload_badzip'] = 'El archivo subido no es un ZIP válido.';
+$string['stylesupload_badentry'] = 'El archivo ZIP contiene entradas que no se pueden leer.';
+$string['stylesupload_unsafe'] = 'Entrada de archivo no segura rechazada: {$a}';
+$string['stylesupload_multiconfig'] = 'El archivo contiene más de un config.xml.';
+$string['stylesupload_noconfig'] = 'Al paquete de estilo le falta el config.xml.';
+$string['stylesupload_mixedroots'] = 'El archivo debe contener una única carpeta raíz o tener todos los archivos en la raíz.';
+$string['stylesupload_badext'] = 'Tipo de archivo no permitido en el paquete de estilo: {$a}';
+$string['stylesupload_configread'] = 'No se pudo leer config.xml del archivo.';
+$string['stylesupload_badxml'] = 'config.xml no es XML válido.';
+$string['stylesupload_noname'] = 'config.xml debe declarar un elemento .';
+$string['stylesupload_traversal'] = 'Se ha bloqueado un intento de escalado de directorios durante la extracción.';
+$string['stylesupload_readfailed'] = 'No se pudo leer un archivo del ZIP durante la extracción.';
+$string['stylesupload_writefailed'] = 'No se pudo escribir un archivo extraído.';
+$string['stylesnocss'] = 'El estilo subido no contiene ninguna hoja de estilos.';
+$string['stylesinstallfailed'] = 'No se pudo instalar el estilo: {$a}';
+$string['stylesuploaded'] = 'Estilos subidos';
+$string['stylesuploaded_empty'] = 'Todavía no hay estilos subidos.';
+$string['stylesuploaded_hint'] = 'Activa o desactiva los estilos subidos. Desmárcalos para ocultarlos del editor; elimínalos para borrarlos definitivamente.';
+$string['stylesbuiltin'] = 'Estilos integrados';
+$string['stylesbuiltin_empty'] = 'Los estilos integrados no están disponibles porque el editor integrado no está instalado.';
+$string['stylesbuiltin_hint'] = 'Desmarca un estilo para ocultarlo del editor. Los estilos integrados desactivados no se eliminan; el proyecto siempre puede recurrir al estilo por defecto.';
+$string['stylestable_title'] = 'Título';
+$string['stylestable_id'] = 'Id';
+$string['stylestable_version'] = 'Versión';
+$string['stylestable_installed'] = 'Instalado';
+$string['stylestable_enabled'] = 'Habilitado';
+$string['stylestable_actions'] = 'Acciones';
+$string['stylesenable'] = 'Habilitar';
+$string['stylesdisable'] = 'Deshabilitar';
+$string['stylesdelete'] = 'Eliminar';
+$string['stylesdelete_confirm'] = '¿Eliminar este estilo? Esta acción no se puede deshacer.';
+$string['stylesdelete_success'] = 'Estilo eliminado.';
diff --git a/settings.php b/settings.php
index 4b5ce66..32774b8 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(
+ 'modsettings',
+ 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,41 @@
''
));
+ // Inline style ZIP upload (native filemanager). Each dropped .zip is
+ // validated + extracted + registered on save; the file is then
+ // removed from the filearea so the next render starts clean.
+ $settings->add(new \mod_exeweb\admin\admin_setting_stylesupload(
+ 'exeweb/styles_drops',
+ get_string('stylesupload_label', 'mod_exeweb'),
+ get_string('stylesupload_hint', 'mod_exeweb',
+ display_size(\mod_exeweb\local\styles_service::get_max_zip_size())),
+ 'styles_drops',
+ 0,
+ [
+ 'accepted_types' => ['.zip'],
+ 'maxbytes' => \mod_exeweb\local\styles_service::get_max_zip_size(),
+ 'maxfiles' => -1,
+ 'subdirs' => 0,
+ ]
+ ));
+
+ // Uploaded styles list (checkbox per style + per-row delete link).
+ $settings->add(new \mod_exeweb\admin\admin_setting_stylesuploaded(
+ 'exeweb/styles_uploaded'
+ ));
+
+ // Built-in styles list (checkbox per style).
+ $settings->add(new \mod_exeweb\admin\admin_setting_stylesbuiltins(
+ 'exeweb/styles_builtins'
+ ));
+
+ $settings->add(new admin_setting_configcheckbox(
+ 'exeweb/stylesblockimport',
+ get_string('stylesblockimport', 'mod_exeweb'),
+ get_string('stylesblockimport_desc', 'mod_exeweb'),
+ 0
+ ));
+
// Connection settings (only relevant for online mode).
// Inline JS to hide/show connection settings based on editor mode selection.
$connectionsettingsdesc = '