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 + . ''; + 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 + . ''; + 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 = '