From b9e825d1386a9f096fd78773548da526af626c63 Mon Sep 17 00:00:00 2001 From: erseco Date: Thu, 23 Apr 2026 18:07:05 +0100 Subject: [PATCH 01/14] feat(styles): administrator-managed style registry for the embedded editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Settings → eXeLearning → Styles section where administrators can upload eXeLearning style ZIPs, enable/disable built-in styles, and enable/disable/delete uploaded ones — without rebuilding the static editor bundle or touching dist/static. Architecture - `ExeLearning_Styles_Service` owns the pure logic: ZIP validation (path traversal, absolute paths, size cap, extension allow-list, single config.xml), slug allocation with collision suffix, registry persistence in the `exelearning_styles_registry` option, and the `build_theme_registry_override()` payload the editor consumes. - `ExeLearning_Admin_Styles` exposes nonce-protected admin-ajax endpoints for upload/toggle/delete, gated on `manage_options`. - Uploaded style bundles extract to `wp-content/uploads/exelearning-styles//` — a sibling of the editor install, so `make build-editor` / the installer cannot destroy admin-managed styles. - `admin/views/editor-bootstrap.php` injects the approved registry into the page as `window.eXeLearning.config.themeRegistryOverride` before the editor scripts load. The static editor merges/filters the bundle themes and refuses install-from-content (core hook landed upstream in exelearning/exelearning#1722). Behavior - Disabled built-ins disappear from the editor's style selector. - Uploaded styles show up alongside built-ins with stable ids. - Projects referencing a missing/disabled style fall back to `base`. - Default max ZIP size is 20 MB, filterable via `exelearning_styles_max_zip_size`. - No mandatory platform-wide "default style" setting is introduced. Tests - `tests/unit/StylesServiceTest.php` covers: reject-missing-config, reject-traversal, reject-disallowed-ext, accept-valid-root, accept-single-root-folder, install extracts + registers, unique-slug on collision, delete removes files + entry, override respects enabled flag and includes the block-import flag. - A standalone smoke run of the ZIP validator + registry math (7 assertions) was executed locally during development. Docs - README "Managing styles" section describes the admin UX, storage location, fallback behavior, and the size filter. --- README.md | 12 + admin/class-admin-settings.php | 224 +++++++++ admin/class-admin-styles.php | 133 ++++++ admin/views/editor-bootstrap.php | 20 + exelearning.php | 4 + includes/class-exelearning.php | 8 + includes/class-styles-service.php | 732 ++++++++++++++++++++++++++++++ tests/unit/StylesServiceTest.php | 256 +++++++++++ 8 files changed, 1389 insertions(+) create mode 100644 admin/class-admin-styles.php create mode 100644 includes/class-styles-service.php create mode 100644 tests/unit/StylesServiceTest.php diff --git a/README.md b/README.md index 497de66..659a6b5 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,18 @@ Replace `123` with the attachment ID of your ELPX file. - ELPX files display metadata including license, language, and resource type - Click on an ELPX file to preview its content +### Managing styles + +Administrators can upload eXeLearning style packages and control which styles the embedded editor exposes from **Settings → eXeLearning → Styles**. + +- Upload one or more `.zip` style packages. A valid package contains a `config.xml` that declares at least a ``, plus a `style.css` and any supporting assets. +- Uploaded styles extract to `wp-content/uploads/exelearning-styles//` and are never written inside `dist/static/`, so reinstalling the embedded editor does not destroy them. +- Each built-in style can be hidden individually. Hidden built-ins disappear from the editor's style selector but remain on disk. +- The editor refuses to install styles from imported content or other unapproved sources while the admin-managed registry is active. +- Projects that reference a disabled or deleted style fall back to the editor's default style instead of failing to open. + +Uploaded ZIPs are validated against path traversal, absolute paths, oversize archives (default 20 MB, filterable via `exelearning_styles_max_zip_size`), and a strict file-extension allow-list. + ## Development For development, you can bring up a local WordPress environment with the plugin pre-installed: diff --git a/admin/class-admin-settings.php b/admin/class-admin-settings.php index 2095a55..bf83d84 100644 --- a/admin/class-admin-settings.php +++ b/admin/class-admin-settings.php @@ -64,10 +64,234 @@ public function display_settings_page() {

render_editor_status_section(); ?> + render_styles_section(); ?> +
+

+

+ +

+ +

+
+

+ + +

+

+ +

+
+ + + +

+ +

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

+ +

+ +

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

+ +

+
+ + + check_common_permissions(); + + if ( empty( $_FILES['style_zip'] ) ) { + wp_send_json_error( array( 'message' => __( 'No file uploaded.', 'exelearning' ) ), 400 ); + } + + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Handled explicitly below. + $file = $_FILES['style_zip']; + if ( ! is_array( $file ) || UPLOAD_ERR_OK !== (int) $file['error'] ) { + wp_send_json_error( + array( 'message' => __( 'File upload failed.', 'exelearning' ) ), + 500 + ); + } + + $tmp_name = isset( $file['tmp_name'] ) ? (string) $file['tmp_name'] : ''; + $orig_name = isset( $file['name'] ) ? sanitize_file_name( (string) $file['name'] ) : ''; + if ( '' === $tmp_name || ! is_uploaded_file( $tmp_name ) ) { + wp_send_json_error( + array( 'message' => __( 'Uploaded file is not accessible.', 'exelearning' ) ), + 500 + ); + } + + $result = ExeLearning_Styles_Service::install_from_zip( $tmp_name, $orig_name ); + if ( is_wp_error( $result ) ) { + wp_send_json_error( array( 'message' => $result->get_error_message() ), 400 ); + } + + wp_send_json_success( + array( + 'message' => __( 'Style installed.', 'exelearning' ), + 'style' => $result, + ) + ); + } + + /** + * Enable or disable an uploaded style. + */ + public function ajax_toggle_uploaded() { + $this->check_common_permissions(); + $slug = isset( $_POST['slug'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['slug'] ) ) : ''; + $enabled = isset( $_POST['enabled'] ) ? (bool) wp_unslash( $_POST['enabled'] ) : false; + if ( '' === $slug ) { + wp_send_json_error( array( 'message' => __( 'Missing style id.', 'exelearning' ) ), 400 ); + } + $result = ExeLearning_Styles_Service::set_uploaded_enabled( $slug, $enabled ); + if ( is_wp_error( $result ) ) { + wp_send_json_error( array( 'message' => $result->get_error_message() ), 400 ); + } + wp_send_json_success( array( 'enabled' => $enabled ) ); + } + + /** + * Enable or disable a built-in style. + */ + public function ajax_toggle_builtin() { + $this->check_common_permissions(); + $id = isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['id'] ) ) : ''; + $enabled = isset( $_POST['enabled'] ) ? (bool) wp_unslash( $_POST['enabled'] ) : false; + if ( '' === $id ) { + wp_send_json_error( array( 'message' => __( 'Missing style id.', 'exelearning' ) ), 400 ); + } + ExeLearning_Styles_Service::set_builtin_enabled( $id, $enabled ); + wp_send_json_success( array( 'enabled' => $enabled ) ); + } + + /** + * Delete an uploaded style. + */ + public function ajax_delete() { + $this->check_common_permissions(); + $slug = isset( $_POST['slug'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['slug'] ) ) : ''; + if ( '' === $slug ) { + wp_send_json_error( array( 'message' => __( 'Missing style id.', 'exelearning' ) ), 400 ); + } + $result = ExeLearning_Styles_Service::delete_uploaded( $slug ); + if ( is_wp_error( $result ) ) { + wp_send_json_error( array( 'message' => $result->get_error_message() ), 400 ); + } + wp_send_json_success(); + } + + /** + * Shared guard for all endpoints: capability + nonce. + */ + private function check_common_permissions() { + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'exelearning' ) ), 403 ); + } + $nonce = isset( $_REQUEST['_ajax_nonce'] ) ? sanitize_text_field( wp_unslash( (string) $_REQUEST['_ajax_nonce'] ) ) : ''; + if ( ! wp_verify_nonce( $nonce, self::AJAX_NONCE ) ) { + wp_send_json_error( array( 'message' => __( 'Invalid or missing security token.', 'exelearning' ) ), 403 ); + } + } +} diff --git a/admin/views/editor-bootstrap.php b/admin/views/editor-bootstrap.php index a0bb925..474ea68 100644 --- a/admin/views/editor-bootstrap.php +++ b/admin/views/editor-bootstrap.php @@ -97,6 +97,17 @@ 'error' => __( 'Error', 'exelearning' ), ); +// Build the approved style registry that the static editor will consume +// via `window.eXeLearning.config.themeRegistryOverride`. +$theme_registry_override = class_exists( 'ExeLearning_Styles_Service' ) + ? ExeLearning_Styles_Service::build_theme_registry_override() + : array( + 'disabledBuiltins' => array(), + 'uploaded' => array(), + 'blockImportInstall' => true, + 'fallbackTheme' => 'base', + ); + // Inject WordPress configuration BEFORE the closing tag. // phpcs:disable WordPress.WP.EnqueuedResources.NonEnqueuedScript -- Standalone HTML page output, not a WordPress template. $wp_config_script = sprintf( @@ -122,6 +133,14 @@ window.__EXE_STATIC_MODE__ = true; window.__EXE_WP_MODE__ = true; + // Approved style registry for the embedded editor. The editor + // merges disabledBuiltins/uploaded at bundle load time and + // refuses any install-from-content path while blockImportInstall + // is truthy. See exelearning/exelearning#1722. + window.eXeLearning = window.eXeLearning || {}; + window.eXeLearning.config = window.eXeLearning.config || {}; + window.eXeLearning.config.themeRegistryOverride = %s; + // Embedding configuration for the editor. // The editor reads this in RuntimeConfig.fromEnvironment() and applies // basePath in App.initializeModeDetection(). UI hiding is done via @@ -402,6 +421,7 @@ function normalizeEditorAssetUrl(url) { $user_id, wp_json_encode( $editor_base_url ), wp_json_encode( $i18n ), + wp_json_encode( $theme_registry_override ), esc_url( $plugin_assets_url ) ); // phpcs:enable WordPress.WP.EnqueuedResources.NonEnqueuedScript diff --git a/exelearning.php b/exelearning.php index a3cad7f..c488337 100644 --- a/exelearning.php +++ b/exelearning.php @@ -48,8 +48,12 @@ require_once EXELEARNING_PLUGIN_DIR . 'includes/class-elp-upload-handler.php'; require_once EXELEARNING_PLUGIN_DIR . 'includes/class-elp-upload-block.php'; +// Styles management (uploaded/builtin registry). +require_once EXELEARNING_PLUGIN_DIR . 'includes/class-styles-service.php'; + // Admin classes. require_once EXELEARNING_PLUGIN_DIR . 'admin/class-admin-settings.php'; +require_once EXELEARNING_PLUGIN_DIR . 'admin/class-admin-styles.php'; require_once EXELEARNING_PLUGIN_DIR . 'admin/class-admin-upload.php'; // Public classes. require_once EXELEARNING_PLUGIN_DIR . 'public/class-shortcodes.php'; diff --git a/includes/class-exelearning.php b/includes/class-exelearning.php index 62f42bb..42d6a1d 100644 --- a/includes/class-exelearning.php +++ b/includes/class-exelearning.php @@ -123,6 +123,13 @@ class ExeLearning { */ private $editor_installer; + /** + * Instance of the admin styles handler. + * + * @var ExeLearning_Admin_Styles + */ + private $admin_styles; + /** * Constructor. */ @@ -163,6 +170,7 @@ private function init_components() { if ( is_admin() ) { $this->editor_installer = new ExeLearning_Static_Editor_Installer(); + $this->admin_styles = new ExeLearning_Admin_Styles(); } } diff --git a/includes/class-styles-service.php b/includes/class-styles-service.php new file mode 100644 index 0000000..d4de4a7 --- /dev/null +++ b/includes/class-styles-service.php @@ -0,0 +1,732 @@ + 0 ? $size : self::DEFAULT_MAX_ZIP_SIZE; + } + + /** + * Load the persisted registry as an associative array. + * + * @return array{uploaded: array, disabled_builtins: string[]} + */ + public static function get_registry() { + $raw = get_option( self::OPTION_REGISTRY, array() ); + if ( ! is_array( $raw ) ) { + $raw = array(); + } + return array( + 'uploaded' => isset( $raw['uploaded'] ) && is_array( $raw['uploaded'] ) ? $raw['uploaded'] : array(), + 'disabled_builtins' => isset( $raw['disabled_builtins'] ) && is_array( $raw['disabled_builtins'] ) + ? array_values( array_map( 'strval', $raw['disabled_builtins'] ) ) + : array(), + ); + } + + /** + * Persist the registry. + * + * @param array $registry Full registry array. + * @return void + */ + public static function save_registry( array $registry ) { + update_option( self::OPTION_REGISTRY, $registry, false ); + } + + /** + * Read the bundled editor's themes list. + * + * Returns an empty array if the editor is not installed, if the bundle + * file is unreadable, or if the JSON is malformed. Failure here is + * non-fatal: the admin UI simply shows no built-ins to disable. + * + * @return array> + */ + public static function list_builtin_themes() { + $bundle_path = EXELEARNING_PLUGIN_DIR . 'dist/static/data/bundle.json'; + if ( ! file_exists( $bundle_path ) || ! is_readable( $bundle_path ) ) { + return array(); + } + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $json = file_get_contents( $bundle_path ); + if ( false === $json || '' === $json ) { + return array(); + } + $data = json_decode( $json, true ); + if ( ! is_array( $data ) || empty( $data['themes'] ) || ! is_array( $data['themes'] ) ) { + return array(); + } + $out = array(); + foreach ( $data['themes'] as $theme ) { + if ( ! is_array( $theme ) || empty( $theme['name'] ) ) { + continue; + } + $out[] = array( + 'id' => (string) $theme['name'], + 'name' => (string) $theme['name'], + 'title' => isset( $theme['title'] ) ? (string) $theme['title'] : (string) $theme['name'], + 'version' => isset( $theme['version'] ) ? (string) $theme['version'] : '', + 'description' => isset( $theme['description'] ) ? (string) $theme['description'] : '', + 'author' => isset( $theme['author'] ) ? (string) $theme['author'] : '', + ); + } + return $out; + } + + /** + * List uploaded styles enriched with computed URL/path info. + * + * @return array> + */ + public static function list_uploaded_styles() { + $registry = self::get_registry(); + $out = array(); + foreach ( $registry['uploaded'] as $slug => $meta ) { + if ( ! is_array( $meta ) ) { + continue; + } + $meta['id'] = (string) $slug; + $meta['name'] = (string) $slug; + $meta['url'] = trailingslashit( self::get_storage_url() ) . rawurlencode( $slug ); + $meta['path'] = trailingslashit( self::get_storage_dir() ) . $slug; + $out[] = $meta; + } + return $out; + } + + /** + * Build the payload consumed by the editor's themeRegistryOverride hook. + * + * @return array{disabledBuiltins: string[], uploaded: array>, blockImportInstall: true, fallbackTheme: string} + */ + public static function build_theme_registry_override() { + $registry = self::get_registry(); + $uploaded = array(); + foreach ( $registry['uploaded'] as $slug => $meta ) { + if ( ! is_array( $meta ) || empty( $meta['enabled'] ) ) { + continue; + } + $css_files = isset( $meta['css_files'] ) && is_array( $meta['css_files'] ) ? array_values( $meta['css_files'] ) : array( 'style.css' ); + $uploaded[] = array( + 'id' => (string) $slug, + 'name' => (string) $slug, + 'dirName' => (string) $slug, + 'title' => isset( $meta['title'] ) ? (string) $meta['title'] : (string) $slug, + 'description' => isset( $meta['description'] ) ? (string) $meta['description'] : '', + 'version' => isset( $meta['version'] ) ? (string) $meta['version'] : '', + 'author' => isset( $meta['author'] ) ? (string) $meta['author'] : '', + 'license' => isset( $meta['license'] ) ? (string) $meta['license'] : '', + 'type' => 'uploaded', + 'url' => trailingslashit( self::get_storage_url() ) . rawurlencode( $slug ), + 'cssFiles' => array_values( array_map( 'strval', $css_files ) ), + 'downloadable' => '0', + 'valid' => true, + ); + } + return array( + 'disabledBuiltins' => $registry['disabled_builtins'], + 'uploaded' => $uploaded, + 'blockImportInstall' => true, + 'fallbackTheme' => 'base', + ); + } + + /** + * Toggle the enabled flag on an uploaded style. + * + * @param string $slug Uploaded style slug. + * @param bool $enabled New enabled state. + * @return true|WP_Error + */ + public static function set_uploaded_enabled( $slug, $enabled ) { + $slug = self::normalize_slug( $slug ); + $registry = self::get_registry(); + if ( ! isset( $registry['uploaded'][ $slug ] ) ) { + return new WP_Error( 'style_not_found', __( 'Style not found.', 'exelearning' ) ); + } + $registry['uploaded'][ $slug ]['enabled'] = (bool) $enabled; + self::save_registry( $registry ); + return true; + } + + /** + * Toggle the enabled flag on a built-in style. + * + * @param string $id Built-in style id/name. + * @param bool $enabled New enabled state (true = enabled, false = hidden). + * @return true + */ + public static function set_builtin_enabled( $id, $enabled ) { + $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 ) ); + } elseif ( ! in_array( $id, $disabled, true ) ) { + $disabled[] = $id; + } + $registry['disabled_builtins'] = $disabled; + self::save_registry( $registry ); + return true; + } + + /** + * Delete an uploaded style (registry entry + files on disk). + * + * @param string $slug Uploaded style slug. + * @return true|WP_Error + */ + public static function delete_uploaded( $slug ) { + $slug = self::normalize_slug( $slug ); + $registry = self::get_registry(); + if ( ! isset( $registry['uploaded'][ $slug ] ) ) { + return new WP_Error( 'style_not_found', __( 'Style not found.', 'exelearning' ) ); + } + $dir = trailingslashit( self::get_storage_dir() ) . $slug; + if ( is_dir( $dir ) ) { + self::recursive_delete( $dir ); + } + unset( $registry['uploaded'][ $slug ] ); + self::save_registry( $registry ); + return true; + } + + /** + * Install an uploaded style ZIP. + * + * Validates the archive, extracts it into a fresh per-slug directory, + * records metadata in the registry, and returns the new entry. + * + * @param string $zip_path Absolute path to the uploaded ZIP on disk. + * @param string $orig_name Original filename (used as fallback slug source). + * @return array|WP_Error Registry entry on success, WP_Error on failure. + */ + public static function install_from_zip( $zip_path, $orig_name = '' ) { + $validation = self::validate_zip( $zip_path ); + if ( is_wp_error( $validation ) ) { + return $validation; + } + + $config = $validation['config']; + $prefix = $validation['prefix']; + + // Derive a stable slug. Prefer the theme's declared name; fall back to + // the uploaded file's basename. Suffix on collision so we never + // clobber an existing entry. + $requested_slug = ! empty( $config['name'] ) + ? $config['name'] + : pathinfo( $orig_name, PATHINFO_FILENAME ); + $slug = self::allocate_unique_slug( $requested_slug ); + + $dest = trailingslashit( self::get_storage_dir() ) . $slug; + if ( ! wp_mkdir_p( $dest ) ) { + return new WP_Error( 'mkdir_failed', __( 'Failed to create style directory.', 'exelearning' ) ); + } + + $extract_result = self::extract_zip_safely( $zip_path, $dest, $prefix ); + if ( is_wp_error( $extract_result ) ) { + self::recursive_delete( $dest ); + return $extract_result; + } + + $css_files = self::find_css_files( $dest ); + if ( empty( $css_files ) ) { + self::recursive_delete( $dest ); + return new WP_Error( + 'style_no_css', + __( 'The uploaded style does not contain any stylesheet.', 'exelearning' ) + ); + } + + // SHA-256 of the original archive so admins can spot identical reuploads. + $checksum = @hash_file( 'sha256', $zip_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort metadata. + $size = @filesize( $zip_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + + $entry = array( + 'title' => isset( $config['title'] ) ? (string) $config['title'] : $slug, + 'version' => isset( $config['version'] ) ? (string) $config['version'] : '', + 'author' => isset( $config['author'] ) ? (string) $config['author'] : '', + 'license' => isset( $config['license'] ) ? (string) $config['license'] : '', + 'description' => isset( $config['description'] ) ? (string) $config['description'] : '', + 'css_files' => $css_files, + 'enabled' => true, + 'installed_at' => gmdate( 'c' ), + 'checksum' => is_string( $checksum ) ? 'sha256:' . $checksum : '', + 'size' => is_int( $size ) ? $size : 0, + ); + + $registry = self::get_registry(); + $registry['uploaded'][ $slug ] = $entry; + self::save_registry( $registry ); + + $entry['id'] = $slug; + $entry['name'] = $slug; + return $entry; + } + + /** + * Validate an uploaded style ZIP without extracting anywhere. + * + * Returns the parsed config and the shared path prefix (either '' or a + * single root directory) so the caller can strip that prefix during + * extraction — style authors commonly wrap the package in a folder. + * + * @param string $zip_path Absolute path to the ZIP. + * @return array{config: array, prefix: string}|WP_Error + */ + public static function validate_zip( $zip_path ) { + if ( ! file_exists( $zip_path ) || ! is_readable( $zip_path ) ) { + return new WP_Error( 'zip_missing', __( 'Uploaded file is missing or unreadable.', 'exelearning' ) ); + } + $size = filesize( $zip_path ); + if ( false === $size || $size <= 0 ) { + return new WP_Error( 'zip_empty', __( 'Uploaded file is empty.', 'exelearning' ) ); + } + if ( $size > self::get_max_zip_size() ) { + return new WP_Error( + 'zip_too_large', + sprintf( + /* translators: %s: human-readable maximum size. */ + __( 'Uploaded style exceeds the maximum allowed size of %s.', 'exelearning' ), + size_format( self::get_max_zip_size() ) + ) + ); + } + + if ( ! class_exists( 'ZipArchive' ) ) { + return new WP_Error( 'zip_not_available', __( 'The ZipArchive PHP extension is not available.', 'exelearning' ) ); + } + + $zip = new ZipArchive(); + $opened = $zip->open( $zip_path, ZipArchive::CHECKCONS ); + if ( true !== $opened ) { + return new WP_Error( 'zip_open_failed', __( 'The uploaded file is not a readable ZIP archive.', 'exelearning' ) ); + } + + $config_path = null; + $prefix = null; + $entries = array(); + + for ( $i = 0; $i < $zip->numFiles; $i++ ) { + $stat = $zip->statIndex( $i ); + if ( false === $stat ) { + $zip->close(); + return new WP_Error( 'zip_bad_entry', __( 'The ZIP archive contains unreadable entries.', 'exelearning' ) ); + } + $name = (string) $stat['name']; + + if ( self::is_unsafe_zip_entry( $name ) ) { + $zip->close(); + return new WP_Error( + 'zip_unsafe_entry', + sprintf( + /* translators: %s: offending entry name. */ + __( 'Rejected unsafe archive entry: %s', 'exelearning' ), + $name + ) + ); + } + + $entries[] = array( + 'name' => $name, + 'size' => isset( $stat['size'] ) ? (int) $stat['size'] : 0, + ); + + $basename = basename( $name ); + if ( 'config.xml' === $basename ) { + if ( null !== $config_path ) { + $zip->close(); + return new WP_Error( 'zip_multiple_configs', __( 'The archive contains more than one config.xml.', 'exelearning' ) ); + } + $config_path = $name; + $dirname = trim( str_replace( '\\', '/', dirname( $name ) ), '/' ); + $prefix = ( '' === $dirname || '.' === $dirname ) ? '' : $dirname . '/'; + } + } + + if ( null === $config_path ) { + $zip->close(); + return new WP_Error( 'zip_missing_config', __( 'The style package is missing config.xml.', 'exelearning' ) ); + } + + // Every entry must live under the same prefix as config.xml so a + // malicious archive cannot sneak in files that escape the package. + foreach ( $entries as $entry ) { + if ( '' === $prefix ) { + if ( false !== strpos( $entry['name'], '/' ) ) { + // Subdirectories at the root are allowed when config.xml is at the root. + continue; + } + } elseif ( 0 !== strpos( $entry['name'], $prefix ) ) { + $zip->close(); + return new WP_Error( + 'zip_mixed_roots', + __( 'The archive must contain a single root folder or place all files at the root.', 'exelearning' ) + ); + } + if ( ! self::is_allowed_filename( $entry['name'] ) ) { + $zip->close(); + return new WP_Error( + 'zip_bad_extension', + sprintf( + /* translators: %s: offending filename. */ + __( 'File type not allowed in style package: %s', 'exelearning' ), + $entry['name'] + ) + ); + } + } + + $config_xml = $zip->getFromName( $config_path ); + $zip->close(); + if ( false === $config_xml ) { + return new WP_Error( 'zip_config_unreadable', __( 'config.xml could not be read from the archive.', 'exelearning' ) ); + } + + $parsed = self::parse_config_xml( $config_xml ); + if ( is_wp_error( $parsed ) ) { + return $parsed; + } + + return array( + 'config' => $parsed, + 'prefix' => $prefix, + ); + } + + /** + * Parse config.xml into a sanitized associative array. + * + * @param string $xml_source Raw XML source. + * @return array|WP_Error + */ + public static function parse_config_xml( $xml_source ) { + $prev_errors = libxml_use_internal_errors( true ); + $prev_entity = null; + // libxml_disable_entity_loader is removed in PHP 8; by default no + // external entities are loaded, so we simply use SimpleXML safely. + if ( function_exists( 'libxml_disable_entity_loader' ) && PHP_VERSION_ID < 80000 ) { + $prev_entity = libxml_disable_entity_loader( true ); + } + $xml = simplexml_load_string( + $xml_source, + 'SimpleXMLElement', + LIBXML_NONET | LIBXML_NOENT + ); + if ( null !== $prev_entity && function_exists( 'libxml_disable_entity_loader' ) ) { + libxml_disable_entity_loader( $prev_entity ); + } + libxml_clear_errors(); + libxml_use_internal_errors( $prev_errors ); + + if ( false === $xml ) { + return new WP_Error( 'style_bad_xml', __( 'config.xml is not valid XML.', 'exelearning' ) ); + } + + $name = isset( $xml->name ) ? trim( (string) $xml->name ) : ''; + if ( '' === $name ) { + return new WP_Error( 'style_missing_name', __( 'config.xml must declare a element.', 'exelearning' ) ); + } + + return array( + 'name' => sanitize_title( $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 the archive's contents into $dest, optionally stripping $prefix. + * + * @param string $zip_path Source archive. + * @param string $dest Destination directory (must exist and be writable). + * @param string $prefix Shared archive prefix to strip; '' means none. + * @return true|WP_Error + */ + private static function extract_zip_safely( $zip_path, $dest, $prefix ) { + $zip = new ZipArchive(); + $opened = $zip->open( $zip_path, ZipArchive::CHECKCONS ); + if ( true !== $opened ) { + return new WP_Error( 'zip_open_failed', __( 'Failed to reopen ZIP archive.', 'exelearning' ) ); + } + $dest_real = rtrim( wp_normalize_path( $dest ), '/' ); + for ( $i = 0; $i < $zip->numFiles; $i++ ) { + $stat = $zip->statIndex( $i ); + if ( false === $stat ) { + continue; + } + $name = (string) $stat['name']; + if ( self::is_unsafe_zip_entry( $name ) ) { + $zip->close(); + return new WP_Error( 'zip_unsafe_entry', __( 'Refused unsafe archive entry during extraction.', 'exelearning' ) ); + } + $relative = $name; + if ( '' !== $prefix ) { + if ( 0 !== strpos( $name, $prefix ) ) { + continue; + } + $relative = substr( $name, strlen( $prefix ) ); + if ( '' === $relative ) { + continue; + } + } + $target = $dest_real . '/' . ltrim( $relative, '/' ); + $target = wp_normalize_path( $target ); + $target_real = wp_normalize_path( $target ); + if ( 0 !== strpos( $target_real, $dest_real . '/' ) && $target_real !== $dest_real ) { + $zip->close(); + return new WP_Error( 'zip_traversal', __( 'Refused path traversal during extraction.', 'exelearning' ) ); + } + if ( '/' === substr( $name, -1 ) ) { + if ( ! wp_mkdir_p( $target ) ) { + $zip->close(); + return new WP_Error( 'zip_mkdir_failed', __( 'Failed to create a directory from the archive.', 'exelearning' ) ); + } + continue; + } + $parent = dirname( $target ); + if ( ! wp_mkdir_p( $parent ) ) { + $zip->close(); + return new WP_Error( 'zip_mkdir_failed', __( 'Failed to create a directory from the archive.', 'exelearning' ) ); + } + $contents = $zip->getFromIndex( $i ); + if ( false === $contents ) { + $zip->close(); + return new WP_Error( 'zip_read_failed', __( 'Failed to read a file from the archive.', 'exelearning' ) ); + } + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + $written = file_put_contents( $target, $contents ); + if ( false === $written ) { + $zip->close(); + return new WP_Error( 'zip_write_failed', __( 'Failed to write an extracted file.', 'exelearning' ) ); + } + } + $zip->close(); + return true; + } + + /** + * Flag entries that must never be extracted: absolute paths, traversal, + * stream schemes, backslashes, empty names. + * + * @param string $name Raw archive entry name. + * @return bool + */ + private static function is_unsafe_zip_entry( $name ) { + if ( '' === $name ) { + return true; + } + if ( false !== strpos( $name, '\\' ) ) { + return true; + } + if ( 0 === strpos( $name, '/' ) ) { + return true; + } + if ( preg_match( '#^[a-zA-Z]+://#', $name ) ) { + return true; + } + if ( preg_match( '#(^|/)\.\.(/|$)#', $name ) ) { + return true; + } + return false; + } + + /** + * Whether a file inside the archive has an allow-listed extension. + * + * Directory entries (trailing slash) are always allowed. + * + * @param string $name Entry name. + * @return bool + */ + private static function is_allowed_filename( $name ) { + if ( '' === $name || '/' === substr( $name, -1 ) ) { + return true; + } + $ext = strtolower( pathinfo( $name, PATHINFO_EXTENSION ) ); + if ( '' === $ext ) { + // Disallow extensionless files; style packages only need typed assets. + return false; + } + return in_array( $ext, self::ALLOWED_EXTENSIONS, true ); + } + + /** + * Scan the extracted directory for available stylesheets. + * + * The editor prioritizes `style.css`; if that's present it's listed first. + * + * @param string $dir Directory to scan. + * @return string[] File names relative to $dir. + */ + private static function find_css_files( $dir ) { + $dir = trailingslashit( $dir ); + $out = array(); + if ( file_exists( $dir . 'style.css' ) ) { + $out[] = 'style.css'; + } + $glob = glob( $dir . '*.css' ); + if ( is_array( $glob ) ) { + foreach ( $glob as $file ) { + $base = basename( $file ); + if ( ! in_array( $base, $out, true ) ) { + $out[] = $base; + } + } + } + return $out; + } + + /** + * Normalize a user-supplied id so it is safe to embed in paths and URLs. + * + * @param string $slug Raw slug. + * @return string + */ + public static function normalize_slug( $slug ) { + $slug = sanitize_title( (string) $slug ); + return '' === $slug ? 'style' : $slug; + } + + /** + * Allocate a slug that does not collide with built-ins or existing uploads. + * + * @param string $requested Desired slug before disambiguation. + * @return string + */ + public static function allocate_unique_slug( $requested ) { + $base = self::normalize_slug( $requested ); + $builtin = 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( $builtin, $existing ); + $slug = $base; + $i = 2; + while ( in_array( strtolower( $slug ), $taken, true ) ) { + $slug = $base . '-' . $i; + ++$i; + } + return $slug; + } + + /** + * Recursively delete a directory. Safe to call on a missing path. + * + * @param string $dir Absolute path. + * @return void + */ + public static function recursive_delete( $dir ) { + if ( ! file_exists( $dir ) ) { + return; + } + if ( is_link( $dir ) || is_file( $dir ) ) { + wp_delete_file( $dir ); + return; + } + $items = array_diff( scandir( $dir ), array( '.', '..' ) ); + foreach ( $items as $item ) { + self::recursive_delete( $dir . DIRECTORY_SEPARATOR . $item ); + } + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir + @rmdir( $dir ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + } +} diff --git a/tests/unit/StylesServiceTest.php b/tests/unit/StylesServiceTest.php new file mode 100644 index 0000000..ea3f797 --- /dev/null +++ b/tests/unit/StylesServiceTest.php @@ -0,0 +1,256 @@ +assertSame( array(), $r['uploaded'] ); + $this->assertSame( array(), $r['disabled_builtins'] ); + } + + public function test_set_builtin_enabled_toggles_disabled_list() { + ExeLearning_Styles_Service::set_builtin_enabled( 'zen', false ); + $r = ExeLearning_Styles_Service::get_registry(); + $this->assertSame( array( 'zen' ), $r['disabled_builtins'] ); + + // Idempotent add. + ExeLearning_Styles_Service::set_builtin_enabled( 'zen', false ); + $r = ExeLearning_Styles_Service::get_registry(); + $this->assertSame( array( 'zen' ), $r['disabled_builtins'] ); + + ExeLearning_Styles_Service::set_builtin_enabled( 'zen', true ); + $r = ExeLearning_Styles_Service::get_registry(); + $this->assertSame( array(), $r['disabled_builtins'] ); + } + + public function test_set_uploaded_enabled_returns_error_on_unknown_slug() { + $result = ExeLearning_Styles_Service::set_uploaded_enabled( 'nope', true ); + $this->assertInstanceOf( 'WP_Error', $result ); + } + + public function test_validate_zip_rejects_missing_config() { + $zip_path = $this->make_zip( + array( + 'style.css' => '.x{}', + ) + ); + $result = ExeLearning_Styles_Service::validate_zip( $zip_path ); + $this->assertInstanceOf( 'WP_Error', $result ); + $this->assertSame( 'zip_missing_config', $result->get_error_code() ); + wp_delete_file( $zip_path ); + } + + public function test_validate_zip_rejects_traversal_entry() { + $zip_path = $this->make_zip( + array( + 'config.xml' => $this->sample_config_xml( 'acme' ), + '../evil.css' => 'pwn', + ) + ); + $result = ExeLearning_Styles_Service::validate_zip( $zip_path ); + $this->assertInstanceOf( 'WP_Error', $result ); + $this->assertSame( 'zip_unsafe_entry', $result->get_error_code() ); + wp_delete_file( $zip_path ); + } + + public function test_validate_zip_rejects_disallowed_extension() { + $zip_path = $this->make_zip( + array( + 'config.xml' => $this->sample_config_xml( 'acme' ), + 'evil.php' => '', + ) + ); + $result = ExeLearning_Styles_Service::validate_zip( $zip_path ); + $this->assertInstanceOf( 'WP_Error', $result ); + $this->assertSame( 'zip_bad_extension', $result->get_error_code() ); + wp_delete_file( $zip_path ); + } + + public function test_validate_zip_accepts_valid_package_at_root() { + $zip_path = $this->make_zip( + array( + 'config.xml' => $this->sample_config_xml( 'acme-2026', 'Acme 2026', '1.0.0' ), + 'style.css' => 'body { color: #000; }', + ) + ); + $result = ExeLearning_Styles_Service::validate_zip( $zip_path ); + $this->assertIsArray( $result ); + $this->assertSame( 'acme-2026', $result['config']['name'] ); + $this->assertSame( 'Acme 2026', $result['config']['title'] ); + $this->assertSame( '', $result['prefix'] ); + wp_delete_file( $zip_path ); + } + + public function test_validate_zip_accepts_single_root_folder() { + $zip_path = $this->make_zip( + array( + 'acme/config.xml' => $this->sample_config_xml( 'acme' ), + 'acme/style.css' => 'body{}', + ) + ); + $result = ExeLearning_Styles_Service::validate_zip( $zip_path ); + $this->assertIsArray( $result ); + $this->assertSame( 'acme/', $result['prefix'] ); + wp_delete_file( $zip_path ); + } + + public function test_install_from_zip_installs_extracts_and_registers() { + $zip_path = $this->make_zip( + array( + 'config.xml' => $this->sample_config_xml( 'acme', 'Acme', '1.0.0' ), + 'style.css' => 'body { color: red; }', + ) + ); + $entry = ExeLearning_Styles_Service::install_from_zip( $zip_path, 'acme.zip' ); + $this->assertIsArray( $entry ); + $this->assertSame( 'acme', $entry['name'] ); + $this->assertTrue( $entry['enabled'] ); + $this->assertContains( 'style.css', $entry['css_files'] ); + + $extracted_css = ExeLearning_Styles_Service::get_storage_dir() . '/acme/style.css'; + $this->assertFileExists( $extracted_css ); + + $registry = ExeLearning_Styles_Service::get_registry(); + $this->assertArrayHasKey( 'acme', $registry['uploaded'] ); + + wp_delete_file( $zip_path ); + } + + public function test_install_from_zip_generates_unique_slug_on_collision() { + $zip1 = $this->make_zip( + array( + 'config.xml' => $this->sample_config_xml( 'duo' ), + 'style.css' => 'a{}', + ) + ); + $zip2 = $this->make_zip( + array( + 'config.xml' => $this->sample_config_xml( 'duo' ), + 'style.css' => 'b{}', + ) + ); + $a = ExeLearning_Styles_Service::install_from_zip( $zip1 ); + $b = ExeLearning_Styles_Service::install_from_zip( $zip2 ); + $this->assertSame( 'duo', $a['name'] ); + $this->assertSame( 'duo-2', $b['name'] ); + + wp_delete_file( $zip1 ); + wp_delete_file( $zip2 ); + } + + public function test_delete_uploaded_removes_files_and_registry_entry() { + $zip_path = $this->make_zip( + array( + 'config.xml' => $this->sample_config_xml( 'bye' ), + 'style.css' => 'x{}', + ) + ); + ExeLearning_Styles_Service::install_from_zip( $zip_path ); + $dir = ExeLearning_Styles_Service::get_storage_dir() . '/bye'; + $this->assertDirectoryExists( $dir ); + + $result = ExeLearning_Styles_Service::delete_uploaded( 'bye' ); + $this->assertTrue( $result ); + $this->assertDirectoryDoesNotExist( $dir ); + $registry = ExeLearning_Styles_Service::get_registry(); + $this->assertArrayNotHasKey( 'bye', $registry['uploaded'] ); + + wp_delete_file( $zip_path ); + } + + public function test_build_theme_registry_override_respects_enabled_flag() { + $zip_path = $this->make_zip( + array( + 'config.xml' => $this->sample_config_xml( 'seen' ), + 'style.css' => 'a{}', + ) + ); + ExeLearning_Styles_Service::install_from_zip( $zip_path ); + ExeLearning_Styles_Service::set_builtin_enabled( 'zen', false ); + + $override = ExeLearning_Styles_Service::build_theme_registry_override(); + $this->assertSame( array( 'zen' ), $override['disabledBuiltins'] ); + $this->assertTrue( $override['blockImportInstall'] ); + $this->assertSame( 'base', $override['fallbackTheme'] ); + $this->assertCount( 1, $override['uploaded'] ); + $this->assertSame( 'seen', $override['uploaded'][0]['id'] ); + + // Disabling hides it from the override. + ExeLearning_Styles_Service::set_uploaded_enabled( 'seen', false ); + $override = ExeLearning_Styles_Service::build_theme_registry_override(); + $this->assertCount( 0, $override['uploaded'] ); + + wp_delete_file( $zip_path ); + } + + /** + * Build a ZIP file containing the given entries in a temp location. + * + * @param array $entries Map of entry-name => contents. + * @return string Absolute path to the created ZIP. + */ + private function make_zip( array $entries ) { + $path = wp_tempnam( 'styles-test.zip' ); + // wp_tempnam creates an empty file; ZipArchive needs to overwrite it. + wp_delete_file( $path ); + $zip = new ZipArchive(); + $this->assertTrue( true === $zip->open( $path, ZipArchive::CREATE ) ); + foreach ( $entries as $name => $contents ) { + $zip->addFromString( $name, $contents ); + } + $zip->close(); + return $path; + } + + /** + * Minimal valid eXeLearning style config.xml. + * + * @param string $name Theme id. + * @param string $title Human-readable title. + * @param string $version Version string. + * @return string + */ + private function sample_config_xml( $name, $title = '', $version = '1.0.0' ) { + $title = '' === $title ? ucfirst( $name ) : $title; + return '' + . '' + . '' . esc_html( $name ) . '' + . '' . esc_html( $title ) . '' + . '' . esc_html( $version ) . '' + . 'Test' + . 'CC-BY-SA' + . 'Test theme.' + . ''; + } +} From 3c450ac0931b8507ba43a76492ff436bb78ace77 Mon Sep 17 00:00:00 2001 From: erseco Date: Thu, 23 Apr 2026 18:18:29 +0100 Subject: [PATCH 02/14] fix(styles): read bundle.json themes through the double-nested shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundled editor serializes installed themes at bundle.themes.themes (because the build script stores the raw API response). My first implementation read bundle.themes directly, which is a map containing a single 'themes' key — so list_builtin_themes() found the map-shaped value non-empty but no entries matched the iteration contract, and the admin UI showed 'Built-in styles are not available because the embedded editor is not installed' even when the editor was installed. Accept both shapes (flat array or double-nested object) so the reader is robust against future layout changes too. --- includes/class-styles-service.php | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/includes/class-styles-service.php b/includes/class-styles-service.php index d4de4a7..542f1cd 100644 --- a/includes/class-styles-service.php +++ b/includes/class-styles-service.php @@ -133,6 +133,11 @@ public static function save_registry( array $registry ) { * file is unreadable, or if the JSON is malformed. Failure here is * non-fatal: the admin UI simply shows no built-ins to disable. * + * The bundle stores the themes array double-nested as + * `{ themes: { themes: [ ... ] } }` because the build script serializes + * the raw API response shape. Older/alternative builds used a flat + * array — we accept both. + * * @return array> */ public static function list_builtin_themes() { @@ -146,11 +151,21 @@ public static function list_builtin_themes() { return array(); } $data = json_decode( $json, true ); - if ( ! is_array( $data ) || empty( $data['themes'] ) || ! is_array( $data['themes'] ) ) { + if ( ! is_array( $data ) || empty( $data['themes'] ) ) { + return array(); + } + + $themes = $data['themes']; + // Accept either `themes: [..]` (flat) or `themes: { themes: [..] }` (bundled shape). + if ( is_array( $themes ) && isset( $themes['themes'] ) && is_array( $themes['themes'] ) ) { + $themes = $themes['themes']; + } + if ( ! is_array( $themes ) ) { return array(); } + $out = array(); - foreach ( $data['themes'] as $theme ) { + foreach ( $themes as $theme ) { if ( ! is_array( $theme ) || empty( $theme['name'] ) ) { continue; } From 5c0e44fd7dc6cace88310bc0e9b827f7456797e2 Mon Sep 17 00:00:00 2001 From: erseco Date: Thu, 23 Apr 2026 18:20:32 +0100 Subject: [PATCH 03/14] test(styles): cover both bundle.json shapes for builtin discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the bundle → themes decode path into a pure helper (extract_themes_from_bundle) so it can be asserted against both the double-nested shape the core build emits today and the flat-array shape earlier/alternative builds may produce. --- includes/class-styles-service.php | 17 +++++++++++---- tests/unit/StylesServiceTest.php | 35 +++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/includes/class-styles-service.php b/includes/class-styles-service.php index 542f1cd..95f4432 100644 --- a/includes/class-styles-service.php +++ b/includes/class-styles-service.php @@ -151,19 +151,28 @@ public static function list_builtin_themes() { return array(); } $data = json_decode( $json, true ); - if ( ! is_array( $data ) || empty( $data['themes'] ) ) { + return self::extract_themes_from_bundle( is_array( $data ) ? $data : array() ); + } + + /** + * Walk a decoded bundle.json payload and return a normalized list of + * theme entries. Accepts both the double-nested shape the core build + * produces and the flat shape for forward/backward compatibility. + * + * @param array $data Decoded bundle. + * @return array> + */ + public static function extract_themes_from_bundle( array $data ) { + if ( empty( $data['themes'] ) ) { return array(); } - $themes = $data['themes']; - // Accept either `themes: [..]` (flat) or `themes: { themes: [..] }` (bundled shape). if ( is_array( $themes ) && isset( $themes['themes'] ) && is_array( $themes['themes'] ) ) { $themes = $themes['themes']; } if ( ! is_array( $themes ) ) { return array(); } - $out = array(); foreach ( $themes as $theme ) { if ( ! is_array( $theme ) || empty( $theme['name'] ) ) { diff --git a/tests/unit/StylesServiceTest.php b/tests/unit/StylesServiceTest.php index ea3f797..cdfdffb 100644 --- a/tests/unit/StylesServiceTest.php +++ b/tests/unit/StylesServiceTest.php @@ -33,6 +33,41 @@ public function tear_down() { parent::tear_down(); } + public function test_extract_themes_from_bundle_accepts_double_nested_shape() { + $decoded = array( + 'version' => 'x', + 'themes' => array( + 'themes' => array( + array( 'name' => 'neo', 'title' => 'Neo', 'version' => '2025' ), + array( 'name' => 'base', 'title' => 'Default' ), + ), + ), + ); + $out = ExeLearning_Styles_Service::extract_themes_from_bundle( $decoded ); + $this->assertCount( 2, $out ); + $this->assertSame( 'neo', $out[0]['id'] ); + $this->assertSame( 'Neo', $out[0]['title'] ); + } + + public function test_extract_themes_from_bundle_accepts_flat_shape() { + $decoded = array( + 'themes' => array( + array( 'name' => 'alpha', 'title' => 'Alpha' ), + ), + ); + $out = ExeLearning_Styles_Service::extract_themes_from_bundle( $decoded ); + $this->assertCount( 1, $out ); + $this->assertSame( 'alpha', $out[0]['name'] ); + } + + public function test_extract_themes_from_bundle_empty_on_missing_themes() { + $this->assertSame( array(), ExeLearning_Styles_Service::extract_themes_from_bundle( array() ) ); + $this->assertSame( + array(), + ExeLearning_Styles_Service::extract_themes_from_bundle( array( 'themes' => 'not-an-array' ) ) + ); + } + public function test_get_registry_returns_default_shape() { $r = ExeLearning_Styles_Service::get_registry(); $this->assertSame( array(), $r['uploaded'] ); From 6fc3cacd83037f7a0f5a321fc776fdca919c0586 Mon Sep 17 00:00:00 2001 From: erseco Date: Thu, 23 Apr 2026 18:30:47 +0100 Subject: [PATCH 04/14] i18n(styles): add Spanish translations for style manager strings Extends the POT and es_ES catalog with the style-upload/management strings introduced for the administrator style registry so that composer check-untranslated passes. --- languages/exelearning-es_ES.mo | Bin 10968 -> 16422 bytes languages/exelearning-es_ES.po | 207 +++++++++++++++++++++++++++++++++ languages/exelearning.pot | 152 ++++++++++++++++++++++++ 3 files changed, 359 insertions(+) diff --git a/languages/exelearning-es_ES.mo b/languages/exelearning-es_ES.mo index e67586860b333f455c5b50b45909fd9ca71467bc..733af2162f3482dbb433d0cc1ec581a198f9be03 100644 GIT binary patch literal 16422 zcmb`N36Nz~dB?BB1qK8K(E+!^rh`nsSwI+j7=~e{freSy=^h6dT<&}CcK3z%-hJMC zUoR7k7+2J|;X+(uh9oX2#|nj{3KJJ>t5T_$N|j}qR9S^(n!Yfd9d1{CNhr5xfF4 z;91}i@DLb*r-KiHbP+rPUJm{R_+s$CK(%{5jV=bKz@6X-d@A?=_;m13L8=B{0xt!> z0gi+J4t^Qj_N*W{5Bw+anc#+J2f=f|jo`K57^waR;JIK1o&(+k(natIQ1dtmiq3~X z(er1Z==&bn0?$1=2)2Oxzze_}6g?jXH-TRTF9Ux9z7#x{L0$;%0yl!kzze|~6kk6A zo)3Nj6div9s@`|Omx2EQ@;|tU$y^Cu1&W^oP;?##PXX@+`5(NQKc|8Bf#Spc9{&Us zf4&Sp7yLW$`QXn%(Q!IVf#hHlC_e85MdwYR`s;(If{UQ)-3^M)d%+{%=Rl2j3Z28M z;8~#hxe%0`wt=eO^WWbJGIa0}@Oj|pK(+rmsQG;p6h9sX)$V7Y`1}kii!a;2E_gdA zdL9Aw{ac{q^FN^E)1tBTX9^TOM?ujy3rhc%K+7+XA%gqCUEsqYT?D@XHSe=vp7^(s zKk9EgD1CVasDAf=s9rGTF$7gF_1~Al%{;#YY=IAX{1JG7=W|d3(H(-X2Hy`J0)Gx_ zUb_+2U0@83gFgnhffpb=>D@HA6}$_)96Sl$41OOR1IJLpcYw#h*ML6)hv429VGH0F zK=rc;rbw?|35qXA{c{M4pL3w*dk+X}g8M-6^8rwDSOJfM-v(8$WrE=4;4bhCFayQc zyFuBL{^3~2QY6y1-4vbQTQa`tf(sCmtTlJ7Doy?v+0PlD?A3!v=j zhoI>DH7I&c#fhl@E5TnrB?ztq)z7Y%xOxY{D|kK%YMgh2w}GDoHNRhiPX{-^tV!_M zpaGA8SA%zfbQwGhPJoYslHbJ$Q*^uxRKJ&l(wpnSTfsMhzXrbpitg{g49W2qpyYZQ zMs^)|A*k^q5LFC%Af_I?9n?HN0d4?44{F?pLCyOs;2!WF!OOrc{L%QwKwbqO24xSA z_~)O2SMhuvFSmoQ1QA8>7Eto~G$?s}9i(dTGY}OH&Sx;$?G6x?3l4!hzyuV%p8;D7e)ehfUD z=ME@&FM#|H?&pv6=Wjvj@hLbLx(;p!UkAPq6dnHzs@{H_%3Hxj@H+5U;7;&blui0{ z7YNIPPk}TEz6~A&&p?ToYA_GJAN&|N0LO49%izbsMew=fZk+dkn%BeN2JjK^Y2Y_N zR3i8$_#E&l3~s>Zg6Du&f#-rZgU7%SJPZ61xC{JiP<%ceW4H1IMehz!dN>bKC3r8W zc7Fv*&%XnTzkd%(j{gZt4^N@f=YeN{qU&N%{GA5HuQ!6?%O8W+fZqaN3ZBEm3&EWr zrWf1_YMi%$N5Kz(n#VtYqAS?#o?i++pT}L`M(|cp_L_p??>(UO`_Dnq_gzr>_Y?3; z@Ojt5GjN6?{_1K|Bnw@d+wW37Ou3nIgd%;snimnqB=_INhEKsB$eadSn+bHj$yqIz>WgkV? zn<+QgJ9HnEkLdd67lH4l$gXt#7DY1G)uOzK@(w+4VFp$|buT&UnsgA%dVD7M28!%r zit;v!Y@xWMZ@TWJpr*kcluuH0eZoQG?^Qf~p!Vh%_o07&3y7&&o89E!y%Ri-a)@$( zGEdRKzsavQ}^blpUWD8EP9McGg3QF6)^l#f$_J)KUJWu_Yq;;7r=F^`i$7VH_Ac#!2` zuNUfti8B-S(kSdM^WF^8WG4NA$M~M*%e^RD{cOTCGJ_;H z;X)Yq!gen*?WhxuXrIqVChE7NZa3mO_RPS ztQG8|Ua*f^C74;bD7Lt_%p8tc&hIuey6=PoF|Zw((V&|QBGp`oddVW@6(d8BOC%66@OfH6dG}&c_IDU~Ee(O50P*92xeKP$G2-A&Nu} zd~toj#IBuE*Mz9WVA?%lylz{LBE!V7#b|*~p%#HJY7`JF%1=cV?XdVM|{Y!d@(Ql82r7zP?ZHt6mp| z663D&cs3SsMU%Q}NMMbI_Qf#F=d1dI1W!~w;>${(6m>>O7)OxY84X&&e!|vBnj7qo zJJBH14ZcV|z&kii>OeT4PQ$Jx){GBJaevfz+L*=1tp&8qp+Rriw2sHaLTWg5{6H96 z=Moyn2wu&`bFHO*&wA<*tr=QvIuLc^(Cm-fifqAwhC7YXwu4c=n51`_NKR+Z$VZksK+~iyj%*lp#CZ*oxH&l`^KXV}v=B#&Rg$1;MU5hmEJyhC zgZ1nnI3z>#4%6PFegpc+!**~ejd68h&ka|z6Q91sJ|y6ZUBr{uX^--p$+U@>i>;uR z=$OTMsfX%VwII~CBMH+QoT^&Kikr0L{7(wWM}=? zg+7uYz)WV3Y=*L?RjN2mwn4&IH+9)qFdgMe7_(q{K3Oy~5xyfz&4Hwgn3K7=UX%vY z*3EffYD#g7?4{)&y&g%E-S?WR*%w(bT1o2?uV z_jqwl-8dzw;!u2suF=Ze+Ep(^>peqe`Uy!m+0+2Hs!&sShi3Kuh@8~7Ar30jVzy~w zBTTOgZ*X@+O{HRFUNQp~2xQVsN3~pUMG!}E3PZ6Zde>B&_nP`X)|+lZ$7)m%qYf`M|sjGigu6$ z3zPbyK?<|ZvMP;UKVl_g?NDSi?7=b9>e_ljt53mx-z0~&2o*xHLBA=&3^i!n}FR%flJkX^0N1E2+nwuPGNn^o6UosqwQRYR#Zh8xSeN~J zQD9Mu*$hGutIx-Vz9kzuHJi-bICEhClN^wdsu5t7)`fP(iMLutszh{=fSE`gU^f!5 zL-9?+Sp6vWC#o^6-)d56MO;Ze&WR&4#Lqp-d;=E%j|@uRQmpXva8Apkewf?9d1Tln zM=ld={|MLj^BO1oDre<^%MOe+Hn(#0Z$g{pSk*5o)p^;lNK{|S&eYSSr!=NBUu0Bb z%4%03#$*aSeyAb_`$zm?=HhAS>9Q=b9>}JIeVj`u=V?<+->Ob2JTJ_Ou$W_Nu2SuN z;iDO>3W@8~vjVb9B5N~MM~(r}QYVVK+DC{;uv|WkzqG3#rPKyX)HX*szHOHIXTskP z?*3G8#O~R6>kbOZcpML2rL6EPJBbMMi7IYNO;6wUFy|)KvXjvX%2`A=$CJZ|wQ)A%j4^ByRp@F~qMjrH=OG zb_bQON7c&xIT8bnQ0@_x>*ZQ?@B7@!2X6ZN$)AJqY{JH3kLLDz6pP zl)ip%uFi(IU0P+?t_z`OkY9Msrc1I*uil~$*ZiOB?sf5`(cZr^J~?lEo3K)7yqaXQ zukqqQl#}!2VdG_)o7QGnWIhX*Xcb}iX^~KKPoQ4iTA575?++9%FPP8sVK%;H%N*fp z)NT=VwnR(t)NN61Ssael9)nugWC3Eg=Ef$wV}%88LPDTra+*(DCMznr9VTl^wwi`%bSMbd`-VU~~0 zq~RbVElSdHv+^eNjKrKQzB5RA$=u4xkagjRO_X>6wZW}kJD0Rs`VUC&34~!9hjyfE z=*%tD$5;XM$IUbf60@3k+%^X$4@^`X*v90%7L3^t!0z!<4(-H*oiZWzee+#MX09LG zQPmUm>}-xr3_6K+1;)*ecARgRIy82ewXl-BF>U{jo9$b-Upcn*^0Dn#&1~H?=H!!2Va%g4>psjiHMnP&rZ zGzh&~hMjojLE_|iGdO`SFf-N#j$p5U9YQO5F=XmhW&ONrhd_z1bf-w2vcn`;N6EK zTFtYp(SF8|;DsJ#(Htw{F20M0-l~bQ|3A_iyByr^Y7{-;y;!Jdu=)W*Z75}j93;?z zKUs*`n{~$xJr=Mj(KFNn@9PahE+YB+OID?hx19MPh8Km%s>d=I4U|476!EQXq z2GR)cuY(FQo5Y6j$Iu-+bUW_FxLC%9B%D((CpOfCXX=f!k(9_*^#s-WU6dTE(u zG5x|J_Eset=3Scthdfy)Oy|V5LhreC(Wm<6!f#Tm?caEgmLJ5{vLpLY*csT48s1GjYGz`j_Ec0Ad)pv+H(}t-XZ5{h`HeDaz zTKCFH6m6YhAYs)oPX4t~(8)sFavA6r=1@(_E;34z>|p4qruGC?eW$n$f)|_RSZS@> zRSa*C-isIk1{DGqF~z-1I>mj+6W9D8oNA}BrK_mP4nS>|?4Mixs=8{lhX-lgri0qB zC1bS#U%K;(6hslRe+5HPPIt`GB^vFqyK5w~(1c=jl2m8Dt_Sfa%ZM-QtIios%?4)!KQ#x$!7LwiqKG*vRk#A3BP)8qfF)92j z$NGusubhy_O}m;^kF%U`)%grebE&0|;W}eoc~A!)$~R?KKN7vi&K0Dubfa z$H*VmXel|lPZWpUJ4nQ}HZltpsqm#i1l`ITZ>7Gu0h}=d;R4ZS^ML0busz}M}U!Cw;tgMw#*sVFXJ78Fu{RMl} z4X>1TI^RgZc7faW>QxwR-Qzrr3xxiGrjSV2@&YRlgr)T-#fcGJSCIs@uG^AQ(VlF{ zXCH93o#Z}?3ftiw%)QpY=DP?(r$pRRffr+dxO7hNo;)?M7@7JL9mKOK5N^h?Qf3bjX&BNyC+s$B6@w zLxmi+Wj2XK_M8*)fXN^)TWwX(E;)nGmfm`Pq9cydUqg76kk#9i(`Y2EZs{yCs!XqC z4%(>U-4&)g<>S&TCK z#;Vik6m8kejaE*u(%?G^N&>0?>%U8Ec591>z&(oK z{DO7?ioD*v(TSISI>I(f!9mxD?bZ=cI4(B**&EjES$a1XOVzrWIqAr5!ZX(O#ZP#k zo0Hwdo!BYwk#_kJ-KiZJMR~)yooGTB4;Qzk`lRfdBvi delta 2281 zcmYM#32f747{~Fqli|k3j_%+bD;tdN=+>4E#=3EDau^L{Kp2M<_BS+)ZMF*x#Z?jy zj1nBLMiTJ|ATAm-S`?3<0TY5z5pV$skzgQ(Xd-IFh!NxW$C^mGe%|+O`##V6yw5)z zdd&6RviN29uon#fw(>WgzrnjSGv?X5|2K=_F6Onk4>RyArsDTF6|Z73j!GN)-gKPA zyaPknkHhe59FFI(!I-$Y$Y2Z$`RT@t#A?jPMx2dHP(R#*1^6Q7;bF|it2$nmss07v5-RKPZzhMmaEyv~~fzl%!v zC=%0rg*wRjq}Naf{2dE1Gt-!HSc>C`Z)P!Y<7!l?yHFeTA;mE}lb`pa5;}+qa11G- z`7*gaoW)$`#i)b=sPDD#Mr_lHI@mhYKHV5sX`W_KfeCEK%cz9rk&W(ZIV!O=yeU8j z>aP1xiN1n5*#I`-XE+LPqYh9&R8?RCYQK7d$w~JuKAVa$JK?;{A9Hm0)d- zG23x1>cp2YfQ4kU7#E-qpU0he0@vbVPSK5TVhqP|kLrQlsH^)hkNWEbpC%Vh;Y8-A zQ6>5n*~_F;&pI536vr$a9KKvn8Cs&d&Z&P6XO&?e*~6GhHw zb|Hzy%>f45_z3C*ALAUnjtyABvsr}>s!8|YbUcQ<%uU`Z(OqN=FEf)j4sBMWnly$A zY;h63jjGTsOx5$BI$@|=Gf=%yin?Q8(opjAg-B6M7|ZYxoQ(UCT+Jy|1+JmG`%lz< z|Dvuijcgd2BGkMQTZnI#Fet&jNK)n#RK^#PN89|7)a4mUGz(cWW$4G2q+79v`2gMt zL?`R28c{vaj0(IQOK}s%6<{|5on#-j;R#fLBC^s+noyrNE>_c6A$({O^Vna{LBo;w@D3 z)U&APcRs3{_n~^?z2y8NswZxso`wuIQ&Ubt9W;a#$#hmQNN3QG>i(B73%@`!XTt_3 z7W1?_oe`(K+7@R`OT3>o!e#%;Zm_@Q9J3$fcH3p{a{HP)Zk@awyEo6V-u#vJ@%)o^ zbHVIPr#%w(+#6XR4M(?Ge_>zZWMPiWE**U%tJT@u-4X6_Jk8N)S2VG1>`hnV>iEMc z_SND_JKy8aj5(g3jggq=!AOUbSTxb?N}MYhOsQz~&5JtWo=8`xZ$3Y7@C5zAI$t2* z3(jcu`x}C_4O0V^4E%n3VDejbMOj_q@3I*#yTjXNFO`35KdRVbS5=1W@yh4y8&yU2 zm#Q6hz}IU_tADoBYg+7;ngUzqKalv*zkq~-vs}5#j|zB5x3eeG(GgCps+*XS$gIDa pVhcm{_Dm?0yDS!tdYl-?2}d_