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..0fc8df8 100644 --- a/admin/class-admin-settings.php +++ b/admin/class-admin-settings.php @@ -64,10 +64,264 @@ public function display_settings_page() {

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

+

+ +

+ +

+

+ +

+

+ +

+ +

+
+

+ + +

+

+ +

+
+ + + +

+ +

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

+ +

+ +

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

+ +

+
+ + + check_common_permissions(); + + // phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce verified in check_common_permissions(). + if ( empty( $_FILES['style_zip'] ) ) { + wp_send_json_error( array( 'message' => __( 'No file uploaded.', 'exelearning' ) ), 400 ); + } + + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Fields are sanitized individually below. + $file = $_FILES['style_zip']; + // phpcs:enable WordPress.Security.NonceVerification.Missing + 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(); + // phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce verified in check_common_permissions(). + $slug = isset( $_POST['slug'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['slug'] ) ) : ''; + $enabled = self::read_bool_post( 'enabled' ); + // phpcs:enable WordPress.Security.NonceVerification.Missing + 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(); + // phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce verified in check_common_permissions(). + $id = isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['id'] ) ) : ''; + $enabled = self::read_bool_post( 'enabled' ); + // phpcs:enable WordPress.Security.NonceVerification.Missing + 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 ) ); + } + + /** + * Toggle the block-on-import flag for user-imported styles. + */ + public function ajax_toggle_block_import() { + $this->check_common_permissions(); + $enabled = self::read_bool_post( 'enabled' ); + ExeLearning_Styles_Service::set_import_blocked( $enabled ); + wp_send_json_success( array( 'enabled' => $enabled ) ); + } + + /** + * Delete an uploaded style. + */ + public function ajax_delete() { + $this->check_common_permissions(); + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in 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(); + } + + /** + * Read a boolean-ish POST field and return it as a strict bool. + * + * Accepts the usual "truthy" string forms submitted by the admin UI and + * satisfies the WPCS input-sanitization sniff by passing the raw value + * through sanitize_text_field before interpretation. + * + * @param string $key POST field name. + * @return bool + */ + private static function read_bool_post( $key ) { + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified by the calling AJAX handler. + if ( ! isset( $_POST[ $key ] ) ) { + return false; + } + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified by the calling AJAX handler. + $raw = sanitize_text_field( wp_unslash( (string) $_POST[ $key ] ) ); + return in_array( strtolower( $raw ), array( '1', 'true', 'on', 'yes' ), true ); + } + + /** + * 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..2d2fe14 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,57 @@ 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. + // + // `userStyles` is the pre-existing ONLINE_THEMES_INSTALL flag the + // editor consults before showing the "install this project theme" + // modal. We mirror blockImportInstall onto it so the modal is also + // suppressed end-to-end. + // + // The static editor boot sequence repeatedly reassigns + // `window.eXeLearning` and `window.eXeLearning.config` (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). + // We trap both assignments so our override survives every reset. + (function() { + var OVERRIDE = %s; + function injectConfig(cfg) { + if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) return cfg; + cfg.themeRegistryOverride = OVERRIDE; + 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); + } + })(); + // Embedding configuration for the editor. // The editor reads this in RuntimeConfig.fromEnvironment() and applies // basePath in App.initializeModeDetection(). UI hiding is done via @@ -271,6 +333,20 @@ function normalizeEditorAssetUrl(url) { return url; } + // The editor always computes asset paths as + // `symfonyURL + theme.url`. For admin-uploaded styles served + // from an absolute URL (e.g. /wp-content/uploads/...), + // that concatenation produces ``. + // Detect a second `http(s)://` inside the URL and strip + // the editor prefix so the absolute URL is used verbatim. + var secondScheme = url.indexOf("http://", 8); + if (secondScheme < 0) { + secondScheme = url.indexOf("https://", 8); + } + if (secondScheme > 0) { + return url.substring(secondScheme); + } + if ( url.startsWith("data:") || url.startsWith("blob:") || @@ -402,6 +478,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..8e071a2 --- /dev/null +++ b/includes/class-styles-service.php @@ -0,0 +1,875 @@ + 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. + * + * 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() { + $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 ); + 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']; + 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'] ) ) { + 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' ); + $files = self::list_uploaded_files( $slug ); + $style_url = trailingslashit( self::get_storage_url() ) . rawurlencode( $slug ); + $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' => 'admin', + 'url' => $style_url, + 'cssFiles' => array_values( array_map( 'strval', $css_files ) ), + 'files' => $files, + 'icons' => self::scan_uploaded_icons( $slug, $style_url ), + 'downloadable' => '0', + 'valid' => true, + ); + } + return array( + 'disabledBuiltins' => $registry['disabled_builtins'], + 'uploaded' => $uploaded, + 'blockImportInstall' => self::is_import_blocked(), + 'fallbackTheme' => 'base', + ); + } + + /** + * Whether the administrator has blocked user-imported styles. + * + * When true the editor hides its "User styles" tab and silently refuses + * to install a style bundled inside an imported .elpx project — the + * WordPress equivalent of eXeLearning's `ONLINE_THEMES_INSTALL=false`. + * + * Defaults to true on first install so the editor stays locked down + * until the admin opts in. + * + * @return bool + */ + public static function is_import_blocked() { + $value = get_option( self::OPTION_BLOCK_IMPORT, null ); + if ( null === $value ) { + return false; + } + return (bool) $value; + } + + /** + * Persist the import-blocked toggle. + * + * @param bool $blocked New state. + * @return void + */ + public static function set_import_blocked( $blocked ) { + update_option( self::OPTION_BLOCK_IMPORT, (bool) $blocked ? 1 : 0, false ); + } + + /** + * 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++ ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- ZipArchive built-in property. + $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 ) { + // phpcs:ignore Generic.PHP.DeprecatedFunctions.Deprecated -- Only invoked on PHP < 8 where it is not deprecated. + $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' ) ) { + // phpcs:ignore Generic.PHP.DeprecatedFunctions.Deprecated -- Only invoked on PHP < 8 where it is not deprecated. + 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++ ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- ZipArchive built-in property. + $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 + */ + public 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 false; + } + $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; + } + + /** + * Walk an uploaded style's extracted directory and return every file + * inside it as a list of forward-slash relative paths. Used to publish + * a per-file manifest to the embedded editor so its preview/export + * pipeline can fetch admin-uploaded styles without a zip bundle. + * + * @param string $slug Uploaded style slug (already validated). + * @return string[] Sorted relative paths; empty array if the directory is missing. + */ + public static function list_uploaded_files( $slug ) { + $slug = self::normalize_slug( $slug ); + $dir = trailingslashit( self::get_storage_dir() ) . $slug; + if ( ! is_dir( $dir ) ) { + return array(); + } + $base_len = strlen( trailingslashit( $dir ) ); + $out = array(); + try { + $iter = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ), + RecursiveIteratorIterator::LEAVES_ONLY + ); + foreach ( $iter as $file_info ) { + if ( ! $file_info->isFile() ) { + continue; + } + $absolute = (string) $file_info->getPathname(); + $relative = substr( $absolute, $base_len ); + $relative = str_replace( DIRECTORY_SEPARATOR, '/', $relative ); + $out[] = $relative; + } + } catch ( Exception $e ) { + return array(); + } + 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 + * (not just built-in themes, where the editor scans the folder itself). + * + * @param string $slug Uploaded style slug (already validated upstream). + * @param string $style_url Absolute URL prefix at which the style is served. + * @return array + */ + public static function scan_uploaded_icons( $slug, $style_url ) { + $slug = self::normalize_slug( $slug ); + $dir = trailingslashit( self::get_storage_dir() ) . $slug . '/icons'; + if ( ! is_dir( $dir ) ) { + return array(); + } + $entries = scandir( $dir ); + if ( false === $entries ) { + return array(); + } + $out = array(); + 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, array( 'png', 'svg', 'gif', 'jpg', 'jpeg' ), true ) ) { + continue; + } + $icon_id = pathinfo( $name, PATHINFO_FILENAME ); + $out[ $icon_id ] = array( + 'id' => $icon_id, + 'title' => $icon_id, + 'type' => 'img', + 'value' => trailingslashit( $style_url ) . 'icons/' . rawurlencode( $name ), + ); + } + ksort( $out ); + 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 ); + } + @rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir,WordPress.PHP.NoSilencedErrors.Discouraged + } +} diff --git a/languages/exelearning-es_ES.mo b/languages/exelearning-es_ES.mo index e675868..a044a1c 100644 Binary files a/languages/exelearning-es_ES.mo and b/languages/exelearning-es_ES.mo differ diff --git a/languages/exelearning-es_ES.po b/languages/exelearning-es_ES.po index 064c9e8..1adac13 100644 --- a/languages/exelearning-es_ES.po +++ b/languages/exelearning-es_ES.po @@ -548,3 +548,222 @@ msgstr "Eliminar" #: assets/js/elp-upload.js:313 msgid "This is an eXeLearning v2 source file. The content will be displayed on the frontend if exported HTML is available." msgstr "Este es un archivo fuente de eXeLearning v2. El contenido se mostrará en el frontend si hay HTML exportado disponible." + +#: admin/class-admin-settings.php:90 +msgid "Styles" +msgstr "Estilos" + +#: admin/class-admin-settings.php:92 +msgid "Upload eXeLearning style packages and control which styles the embedded editor exposes." +msgstr "Sube paquetes de estilos de eXeLearning y controla qué estilos muestra el editor embebido." + +#: admin/class-admin-settings.php:96 +msgid "Import policy" +msgstr "Política de importación" + +#: admin/class-admin-settings.php:100 +msgid "Block user-imported styles" +msgstr "Bloquear estilos importados por usuarios" + +#: admin/class-admin-settings.php:104 +msgid "When enabled, the embedded editor hides the \"User styles\" tab and silently refuses to install a style bundled inside an imported .elpx project. Users may only choose from the admin-approved list below. This mirrors the eXeLearning ONLINE_THEMES_INSTALL=false behavior." +msgstr "Cuando está activado, el editor embebido oculta la pestaña «Estilos de usuario» y rechaza silenciosamente la instalación de cualquier estilo incluido en un proyecto .elpx importado. Los usuarios solo pueden elegir entre la lista aprobada por la administración que se muestra debajo. Este comportamiento replica el de eXeLearning ONLINE_THEMES_INSTALL=false." + +#: admin/class-admin-settings.php:95 +msgid "Upload a new style" +msgstr "Subir un nuevo estilo" + +#: admin/class-admin-settings.php:100 +msgid "Upload style" +msgstr "Subir estilo" + +#. translators: %s: human-readable max file size. +#: admin/class-admin-settings.php:107 +msgid "Maximum file size: %s. Only .zip packages containing a valid config.xml are accepted." +msgstr "Tamaño máximo de archivo: %s. Solo se aceptan paquetes .zip que contengan un config.xml válido." + +#: admin/class-admin-settings.php:116 +msgid "Uploaded styles" +msgstr "Estilos subidos" + +#: admin/class-admin-settings.php:118 +msgid "No uploaded styles yet." +msgstr "Aún no hay estilos subidos." + +#: admin/class-admin-settings.php:124 +#: admin/class-admin-settings.php:167 +msgid "Id" +msgstr "Id" + +#: admin/class-admin-settings.php:125 +#: admin/class-admin-settings.php:168 +msgid "Version" +msgstr "Versión" + +#: admin/class-admin-settings.php:127 +#: admin/class-admin-settings.php:143 +#: admin/class-admin-settings.php:169 +#: admin/class-admin-settings.php:184 +msgid "Enabled" +msgstr "Habilitado" + +#: admin/class-admin-settings.php:128 +msgid "Actions" +msgstr "Acciones" + +#: admin/class-admin-settings.php:157 +msgid "Built-in styles" +msgstr "Estilos integrados" + +#: admin/class-admin-settings.php:160 +msgid "Built-in styles are not available because the embedded editor is not installed." +msgstr "Los estilos integrados no están disponibles porque el editor embebido no está instalado." + +#: admin/class-admin-settings.php:194 +msgid "Disabled built-in styles are hidden from the editor. Uploaded styles can be disabled or deleted at any time. Existing projects that reference a missing style fall back to the editor default." +msgstr "Los estilos integrados deshabilitados se ocultan del editor. Los estilos subidos se pueden deshabilitar o eliminar en cualquier momento. Los proyectos existentes que hagan referencia a un estilo inexistente utilizarán el estilo predeterminado del editor." + +#: admin/class-admin-settings.php:227 +msgid "Uploading…" +msgstr "Subiendo…" + +#: admin/class-admin-settings.php:230 +msgid "Style installed." +msgstr "Estilo instalado." + +#: admin/class-admin-settings.php:233 +msgid "Upload failed." +msgstr "Error al subir." + +#: admin/class-admin-settings.php:236 +#: admin/class-admin-settings.php:258 +#: admin/class-admin-settings.php:286 +msgid "Network error." +msgstr "Error de red." + +#: admin/class-admin-settings.php:254 +msgid "Update failed." +msgstr "Error al actualizar." + +#: admin/class-admin-settings.php:272 +msgid "Delete this style? This cannot be undone." +msgstr "¿Eliminar este estilo? Esta acción no se puede deshacer." + +#: admin/class-admin-settings.php:281 +msgid "Style deleted." +msgstr "Estilo eliminado." + +#: admin/class-admin-settings.php:283 +msgid "Delete failed." +msgstr "Error al eliminar." + +#: admin/class-admin-styles.php:56 +msgid "Uploaded file is not accessible." +msgstr "No se puede acceder al archivo subido." + +#: admin/class-admin-styles.php:82 +#: admin/class-admin-styles.php:99 +#: admin/class-admin-styles.php:112 +msgid "Missing style id." +msgstr "Falta el identificador del estilo." + +#: admin/class-admin-styles.php:130 +msgid "Invalid or missing security token." +msgstr "Token de seguridad no válido o ausente." + +#: includes/class-styles-service.php:238 +#: includes/class-styles-service.php:276 +msgid "Style not found." +msgstr "Estilo no encontrado." + +#: includes/class-styles-service.php:316 +msgid "Failed to create style directory." +msgstr "No se pudo crear el directorio del estilo." + +#: includes/class-styles-service.php:330 +msgid "The uploaded style does not contain any stylesheet." +msgstr "El estilo subido no contiene ninguna hoja de estilos." + +#: includes/class-styles-service.php:372 +msgid "Uploaded file is missing or unreadable." +msgstr "El archivo subido no existe o no se puede leer." + +#: includes/class-styles-service.php:376 +msgid "Uploaded file is empty." +msgstr "El archivo subido está vacío." + +#. translators: %s: human-readable maximum size. +#: includes/class-styles-service.php:383 +msgid "Uploaded style exceeds the maximum allowed size of %s." +msgstr "El estilo subido supera el tamaño máximo permitido de %s." + +#: includes/class-styles-service.php:390 +msgid "The ZipArchive PHP extension is not available." +msgstr "La extensión ZipArchive de PHP no está disponible." + +#: includes/class-styles-service.php:396 +msgid "The uploaded file is not a readable ZIP archive." +msgstr "El archivo subido no es un archivo ZIP legible." + +#: includes/class-styles-service.php:407 +msgid "The ZIP archive contains unreadable entries." +msgstr "El archivo ZIP contiene entradas ilegibles." + +#. translators: %s: offending entry name. +#: includes/class-styles-service.php:417 +msgid "Rejected unsafe archive entry: %s" +msgstr "Entrada no segura rechazada en el archivo: %s" + +#: includes/class-styles-service.php:432 +msgid "The archive contains more than one config.xml." +msgstr "El archivo contiene más de un config.xml." + +#: includes/class-styles-service.php:442 +msgid "The style package is missing config.xml." +msgstr "Al paquete de estilo le falta config.xml." + +#: includes/class-styles-service.php:457 +msgid "The archive must contain a single root folder or place all files at the root." +msgstr "El archivo debe contener una única carpeta raíz o tener todos los ficheros en la raíz." + +#. translators: %s: offending filename. +#: includes/class-styles-service.php:466 +msgid "File type not allowed in style package: %s" +msgstr "Tipo de archivo no permitido en el paquete de estilo: %s" + +#: includes/class-styles-service.php:476 +msgid "config.xml could not be read from the archive." +msgstr "No se pudo leer config.xml del archivo." + +#: includes/class-styles-service.php:516 +msgid "config.xml is not valid XML." +msgstr "config.xml no es XML válido." + +#: includes/class-styles-service.php:521 +msgid "config.xml must declare a element." +msgstr "config.xml debe declarar un elemento ." + +#: includes/class-styles-service.php:546 +msgid "Failed to reopen ZIP archive." +msgstr "No se pudo reabrir el archivo ZIP." + +#: includes/class-styles-service.php:557 +msgid "Refused unsafe archive entry during extraction." +msgstr "Se rechazó una entrada no segura del archivo durante la extracción." + +#: includes/class-styles-service.php:574 +msgid "Refused path traversal during extraction." +msgstr "Se rechazó un intento de escape de ruta durante la extracción." + +#: includes/class-styles-service.php:579 +#: includes/class-styles-service.php:586 +msgid "Failed to create a directory from the archive." +msgstr "No se pudo crear un directorio del archivo." + +#: includes/class-styles-service.php:591 +msgid "Failed to read a file from the archive." +msgstr "No se pudo leer un archivo del archivo comprimido." + +#: includes/class-styles-service.php:597 +msgid "Failed to write an extracted file." +msgstr "No se pudo escribir un archivo extraído." diff --git a/languages/exelearning.pot b/languages/exelearning.pot index 44a3e0b..6f08dba 100644 --- a/languages/exelearning.pot +++ b/languages/exelearning.pot @@ -389,3 +389,164 @@ msgstr "" msgid "This is an eXeLearning v2 source file. The content will be displayed on the frontend if exported HTML is available." msgstr "" + +msgid "Styles" +msgstr "" + +msgid "Upload eXeLearning style packages and control which styles the embedded editor exposes." +msgstr "" + +msgid "Import policy" +msgstr "" + +msgid "Block user-imported styles" +msgstr "" + +msgid "When enabled, the embedded editor hides the \"User styles\" tab and silently refuses to install a style bundled inside an imported .elpx project. Users may only choose from the admin-approved list below. This mirrors the eXeLearning ONLINE_THEMES_INSTALL=false behavior." +msgstr "" + +msgid "Upload a new style" +msgstr "" + +msgid "Upload style" +msgstr "" + +#. translators: %s: human-readable max file size. +#, php-format +msgid "Maximum file size: %s. Only .zip packages containing a valid config.xml are accepted." +msgstr "" + +msgid "Uploaded styles" +msgstr "" + +msgid "No uploaded styles yet." +msgstr "" + +msgid "Id" +msgstr "" + +msgid "Version" +msgstr "" + +msgid "Enabled" +msgstr "" + +msgid "Actions" +msgstr "" + +msgid "Built-in styles" +msgstr "" + +msgid "Built-in styles are not available because the embedded editor is not installed." +msgstr "" + +msgid "Disabled built-in styles are hidden from the editor. Uploaded styles can be disabled or deleted at any time. Existing projects that reference a missing style fall back to the editor default." +msgstr "" + +msgid "Uploading…" +msgstr "" + +msgid "Style installed." +msgstr "" + +msgid "Upload failed." +msgstr "" + +msgid "Network error." +msgstr "" + +msgid "Update failed." +msgstr "" + +msgid "Delete this style? This cannot be undone." +msgstr "" + +msgid "Style deleted." +msgstr "" + +msgid "Delete failed." +msgstr "" + +msgid "Uploaded file is not accessible." +msgstr "" + +msgid "Missing style id." +msgstr "" + +msgid "Invalid or missing security token." +msgstr "" + +msgid "Style not found." +msgstr "" + +msgid "Failed to create style directory." +msgstr "" + +msgid "The uploaded style does not contain any stylesheet." +msgstr "" + +msgid "Uploaded file is missing or unreadable." +msgstr "" + +msgid "Uploaded file is empty." +msgstr "" + +#. translators: %s: human-readable maximum size. +#, php-format +msgid "Uploaded style exceeds the maximum allowed size of %s." +msgstr "" + +msgid "The ZipArchive PHP extension is not available." +msgstr "" + +msgid "The uploaded file is not a readable ZIP archive." +msgstr "" + +msgid "The ZIP archive contains unreadable entries." +msgstr "" + +#. translators: %s: offending entry name. +#, php-format +msgid "Rejected unsafe archive entry: %s" +msgstr "" + +msgid "The archive contains more than one config.xml." +msgstr "" + +msgid "The style package is missing config.xml." +msgstr "" + +msgid "The archive must contain a single root folder or place all files at the root." +msgstr "" + +#. translators: %s: offending filename. +#, php-format +msgid "File type not allowed in style package: %s" +msgstr "" + +msgid "config.xml could not be read from the archive." +msgstr "" + +msgid "config.xml is not valid XML." +msgstr "" + +msgid "config.xml must declare a element." +msgstr "" + +msgid "Failed to reopen ZIP archive." +msgstr "" + +msgid "Refused unsafe archive entry during extraction." +msgstr "" + +msgid "Refused path traversal during extraction." +msgstr "" + +msgid "Failed to create a directory from the archive." +msgstr "" + +msgid "Failed to read a file from the archive." +msgstr "" + +msgid "Failed to write an extracted file." +msgstr "" diff --git a/tests/unit/AdminStylesTest.php b/tests/unit/AdminStylesTest.php new file mode 100644 index 0000000..15468d1 --- /dev/null +++ b/tests/unit/AdminStylesTest.php @@ -0,0 +1,315 @@ +handler = new ExeLearning_Admin_Styles(); + delete_option( ExeLearning_Styles_Service::OPTION_REGISTRY ); + delete_option( ExeLearning_Styles_Service::OPTION_BLOCK_IMPORT ); + $_POST = array(); + $_REQUEST = array(); + $_FILES = array(); + } + + public function tear_down() { + $this->disable_ajax_die_handler(); + delete_option( ExeLearning_Styles_Service::OPTION_REGISTRY ); + delete_option( ExeLearning_Styles_Service::OPTION_BLOCK_IMPORT ); + $_POST = array(); + $_REQUEST = array(); + $_FILES = array(); + parent::tear_down(); + } + + public function test_constructor_registers_all_ajax_actions() { + $expected = array( + 'wp_ajax_exelearning_styles_upload', + 'wp_ajax_exelearning_styles_toggle_uploaded', + 'wp_ajax_exelearning_styles_toggle_builtin', + 'wp_ajax_exelearning_styles_delete', + 'wp_ajax_exelearning_styles_toggle_block_import', + ); + foreach ( $expected as $hook ) { + $this->assertTrue( has_action( $hook ) !== false, "missing hook: $hook" ); + } + } + + public function test_rejects_request_without_manage_options() { + $user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $user_id ); + + $this->enable_ajax_die_handler(); + $response = $this->expect_json_response( + function () { + $this->handler->ajax_delete(); + } + ); + $this->assertFalse( $response['success'] ); + // Message wording depends on the active locale; just require + // that the handler produced a non-empty error message. + $this->assertNotEmpty( $response['data']['message'] ); + } + + public function test_rejects_request_without_nonce() { + wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) ); + $_REQUEST['_ajax_nonce'] = 'not-a-valid-nonce'; + + $this->enable_ajax_die_handler(); + $response = $this->expect_json_response( + function () { + $this->handler->ajax_delete(); + } + ); + $this->assertFalse( $response['success'] ); + $this->assertNotEmpty( $response['data']['message'] ); + } + + public function test_toggle_uploaded_returns_error_when_slug_missing() { + $this->setup_admin(); + + $response = $this->expect_json_response( + function () { + $this->handler->ajax_toggle_uploaded(); + } + ); + $this->assertFalse( $response['success'] ); + $this->assertNotEmpty( $response['data']['message'] ); + } + + public function test_toggle_uploaded_propagates_to_service() { + $this->setup_admin(); + $this->install_fake_style( 'acme' ); + $this->assertTrue( ExeLearning_Styles_Service::get_registry()['uploaded']['acme']['enabled'] ); + + $_POST['slug'] = 'acme'; + $_POST['enabled'] = '0'; + $response = $this->expect_json_response( + function () { + $this->handler->ajax_toggle_uploaded(); + } + ); + $this->assertTrue( $response['success'] ); + $this->assertFalse( ExeLearning_Styles_Service::get_registry()['uploaded']['acme']['enabled'] ); + } + + public function test_toggle_uploaded_rejects_unknown_slug() { + $this->setup_admin(); + $_POST['slug'] = 'does-not-exist'; + $_POST['enabled'] = '1'; + + $response = $this->expect_json_response( + function () { + $this->handler->ajax_toggle_uploaded(); + } + ); + $this->assertFalse( $response['success'] ); + } + + public function test_toggle_builtin_returns_error_when_id_missing() { + $this->setup_admin(); + $response = $this->expect_json_response( + function () { + $this->handler->ajax_toggle_builtin(); + } + ); + $this->assertFalse( $response['success'] ); + } + + public function test_toggle_builtin_propagates_disable_then_enable() { + $this->setup_admin(); + + $_POST['id'] = 'zen'; + $_POST['enabled'] = '0'; + $this->expect_json_response( + function () { + $this->handler->ajax_toggle_builtin(); + } + ); + $this->assertContains( 'zen', ExeLearning_Styles_Service::get_registry()['disabled_builtins'] ); + + $_POST['enabled'] = '1'; + $this->expect_json_response( + function () { + $this->handler->ajax_toggle_builtin(); + } + ); + $this->assertNotContains( 'zen', ExeLearning_Styles_Service::get_registry()['disabled_builtins'] ); + } + + public function test_delete_returns_error_when_slug_missing() { + $this->setup_admin(); + $response = $this->expect_json_response( + function () { + $this->handler->ajax_delete(); + } + ); + $this->assertFalse( $response['success'] ); + } + + public function test_delete_removes_uploaded_style() { + $this->setup_admin(); + $this->install_fake_style( 'bye' ); + $dir = ExeLearning_Styles_Service::get_storage_dir() . '/bye'; + $this->assertDirectoryExists( $dir ); + + $_POST['slug'] = 'bye'; + $response = $this->expect_json_response( + function () { + $this->handler->ajax_delete(); + } + ); + $this->assertTrue( $response['success'] ); + $this->assertDirectoryDoesNotExist( $dir ); + } + + public function test_toggle_block_import_round_trip() { + $this->setup_admin(); + + $_POST['enabled'] = '1'; + $this->expect_json_response( + function () { + $this->handler->ajax_toggle_block_import(); + } + ); + $this->assertTrue( ExeLearning_Styles_Service::is_import_blocked() ); + + $_POST['enabled'] = ''; + $this->expect_json_response( + function () { + $this->handler->ajax_toggle_block_import(); + } + ); + $this->assertFalse( ExeLearning_Styles_Service::is_import_blocked() ); + } + + public function test_upload_rejects_missing_file() { + $this->setup_admin(); + $response = $this->expect_json_response( + function () { + $this->handler->ajax_upload(); + } + ); + $this->assertFalse( $response['success'] ); + $this->assertNotEmpty( $response['data']['message'] ); + } + + public function test_upload_rejects_broken_upload() { + $this->setup_admin(); + $_FILES['style_zip'] = array( + 'error' => UPLOAD_ERR_PARTIAL, + 'name' => 'bad.zip', + 'size' => 10, + 'tmp_name' => '', + ); + $response = $this->expect_json_response( + function () { + $this->handler->ajax_upload(); + } + ); + $this->assertFalse( $response['success'] ); + } + + // ------------------------------------------------------------------ + // Helpers. + // ------------------------------------------------------------------ + + /** + * Create an admin and seed POST with a valid nonce so the guard passes. + */ + private function setup_admin() { + wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) ); + $_REQUEST['_ajax_nonce'] = wp_create_nonce( ExeLearning_Admin_Styles::AJAX_NONCE ); + $this->enable_ajax_die_handler(); + } + + /** + * Run the given callable expecting wp_send_json_* to die, and return + * the JSON payload that was captured on its way out. + * + * @param callable $fn + * @return array + */ + private function expect_json_response( callable $fn ) { + ob_start(); + try { + $fn(); + $this->fail( 'Expected WPDieException but none was thrown.' ); + } catch ( WPDieException $e ) { + // Normal exit path for AJAX endpoints. + } + $json = ob_get_clean(); + $decoded = json_decode( $json, true ); + $this->assertIsArray( $decoded, 'AJAX handler did not emit JSON' ); + return $decoded; + } + + /** + * Install a small, valid style on disk and in the registry. + */ + private function install_fake_style( $slug ) { + $zip_path = wp_tempnam( $slug . '.zip' ); + wp_delete_file( $zip_path ); + $zip = new ZipArchive(); + $zip->open( $zip_path, ZipArchive::CREATE ); + $zip->addFromString( 'config.xml', + '' . $slug . '' + . '' . ucfirst( $slug ) . '1.0' + ); + $zip->addFromString( 'style.css', 'body{}' ); + $zip->close(); + ExeLearning_Styles_Service::install_from_zip( $zip_path, $slug . '.zip' ); + wp_delete_file( $zip_path ); + } + + private function enable_ajax_die_handler() { + add_filter( 'wp_doing_ajax', '__return_true' ); + add_filter( + 'wp_die_ajax_handler', + function () { + return array( $this, 'wp_die_handler' ); + }, + 1 + ); + } + + private function disable_ajax_die_handler() { + remove_filter( 'wp_doing_ajax', '__return_true' ); + remove_all_filters( 'wp_die_ajax_handler' ); + } + + /** + * Die handler that raises WPDieException instead of exiting the process. + * + * Signature matches WP_UnitTestCase_Base::wp_die_handler so PHP's + * strict LSP check during class loading does not blow up the whole + * test file. + * + * @param string|WP_Error $message Die message. + * @param string $title Page title. + * @param string|array $args wp_die args. + */ + public function wp_die_handler( $message, $title = '', $args = array() ) { + throw new WPDieException( is_scalar( $message ) ? (string) $message : '' ); + } +} diff --git a/tests/unit/StylesServiceTest.php b/tests/unit/StylesServiceTest.php new file mode 100644 index 0000000..dddcec1 --- /dev/null +++ b/tests/unit/StylesServiceTest.php @@ -0,0 +1,583 @@ + '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'] ); + $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() { + // PHP's ZipArchive::addFromString may normalize leading "../" away + // on some builds, so we trigger the guard with a path that every + // stable build preserves verbatim ("foo/../evil.css"). + $zip_path = $this->make_zip( + array( + 'config.xml' => $this->sample_config_xml( 'acme' ), + 'foo/../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 ); + } + + /** + * Covers the pure ZIP-entry safety matrix without depending on the + * ZipArchive build-to-build name-normalization differences. + * + * @dataProvider unsafe_entry_provider + */ + public function test_is_unsafe_zip_entry_matrix( $name, $unsafe ) { + $this->assertSame( $unsafe, ExeLearning_Styles_Service::is_unsafe_zip_entry( $name ) ); + } + + public function unsafe_entry_provider() { + return array( + 'empty' => array( '', true ), + 'backslash' => array( 'a\\b', true ), + 'absolute' => array( '/abs.css', true ), + 'stream scheme' => array( 'http://x', true ), + 'parent at root' => array( '../evil', true ), + 'parent mid path' => array( 'a/../b', true ), + 'parent at end' => array( 'a/..', true ), + 'normal file' => array( 'style.css', false ), + 'nested file' => array( 'icons/a.svg', false ), + 'deep nested' => array( 'img/icons/sub/a.png', false ), + ); + } + + 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_set_uploaded_enabled_returns_wp_error_on_unknown_slug() { + $this->assertInstanceOf( 'WP_Error', ExeLearning_Styles_Service::set_uploaded_enabled( 'missing', true ) ); + } + + public function test_delete_uploaded_returns_wp_error_on_unknown_slug() { + $this->assertInstanceOf( 'WP_Error', ExeLearning_Styles_Service::delete_uploaded( 'missing' ) ); + } + + public function test_is_import_blocked_defaults_to_false() { + $this->assertFalse( ExeLearning_Styles_Service::is_import_blocked() ); + } + + public function test_is_import_blocked_follows_the_option() { + ExeLearning_Styles_Service::set_import_blocked( true ); + $this->assertTrue( ExeLearning_Styles_Service::is_import_blocked() ); + ExeLearning_Styles_Service::set_import_blocked( false ); + $this->assertFalse( ExeLearning_Styles_Service::is_import_blocked() ); + } + + public function test_normalize_slug_sanitizes_input() { + $this->assertSame( 'a-b-c', ExeLearning_Styles_Service::normalize_slug( 'A B C' ) ); + $this->assertSame( 'style', ExeLearning_Styles_Service::normalize_slug( ' ' ) ); + } + + public function test_validate_zip_rejects_missing_file() { + $result = ExeLearning_Styles_Service::validate_zip( '/nonexistent.zip' ); + $this->assertInstanceOf( 'WP_Error', $result ); + $this->assertSame( 'zip_missing', $result->get_error_code() ); + } + + public function test_validate_zip_rejects_empty_file() { + $empty = wp_tempnam( 'empty.zip' ); + file_put_contents( $empty, '' ); + $result = ExeLearning_Styles_Service::validate_zip( $empty ); + $this->assertInstanceOf( 'WP_Error', $result ); + $this->assertContains( $result->get_error_code(), array( 'zip_empty', 'zip_missing' ) ); + wp_delete_file( $empty ); + } + + public function test_validate_zip_rejects_non_zip_payload() { + $notzip = wp_tempnam( 'notzip.zip' ); + file_put_contents( $notzip, 'this is not a zip archive' ); + $result = ExeLearning_Styles_Service::validate_zip( $notzip ); + $this->assertInstanceOf( 'WP_Error', $result ); + $this->assertSame( 'zip_open_failed', $result->get_error_code() ); + wp_delete_file( $notzip ); + } + + public function test_validate_zip_rejects_multiple_config_files() { + $zip_path = $this->make_zip( + array( + 'a/config.xml' => $this->sample_config_xml( 'a' ), + 'b/config.xml' => $this->sample_config_xml( 'b' ), + 'a/style.css' => 'x{}', + 'b/style.css' => 'y{}', + ) + ); + $result = ExeLearning_Styles_Service::validate_zip( $zip_path ); + $this->assertInstanceOf( 'WP_Error', $result ); + wp_delete_file( $zip_path ); + } + + public function test_parse_config_xml_rejects_invalid_xml() { + $result = ExeLearning_Styles_Service::parse_config_xml( '<assertInstanceOf( 'WP_Error', $result ); + $this->assertSame( 'style_bad_xml', $result->get_error_code() ); + } + + public function test_parse_config_xml_requires_name() { + $result = ExeLearning_Styles_Service::parse_config_xml( + '' + ); + $this->assertInstanceOf( 'WP_Error', $result ); + $this->assertSame( 'style_missing_name', $result->get_error_code() ); + } + + public function test_parse_config_xml_accepts_minimum_fields() { + $result = ExeLearning_Styles_Service::parse_config_xml( + 'min' + ); + $this->assertIsArray( $result ); + $this->assertSame( 'min', $result['name'] ); + $this->assertSame( 'min', $result['title'] ); + } + + public function test_extract_themes_from_bundle_ignores_malformed_entries() { + $out = ExeLearning_Styles_Service::extract_themes_from_bundle( + array( + 'themes' => array( + 'themes' => array( + array( 'title' => 'no-name' ), + 'not-an-array', + array( 'name' => 'ok', 'title' => 'OK' ), + ), + ), + ) + ); + $this->assertCount( 1, $out ); + $this->assertSame( 'ok', $out[0]['id'] ); + } + + public function test_list_uploaded_styles_skips_scalar_entries() { + $bad = array( + 'uploaded' => array( + 'good' => array( 'title' => 'Good', 'enabled' => true ), + 'bad' => 'scalar', + ), + 'disabled_builtins' => array(), + ); + update_option( ExeLearning_Styles_Service::OPTION_REGISTRY, $bad, false ); + $list = ExeLearning_Styles_Service::list_uploaded_styles(); + $this->assertCount( 1, $list ); + $this->assertSame( 'good', $list[0]['id'] ); + } + + public function test_build_override_skips_non_array_and_disabled_entries() { + $seed = array( + 'uploaded' => array( + 'on' => array( 'title' => 'On', 'enabled' => true, 'css_files' => array( 'style.css' ) ), + 'off' => array( 'title' => 'Off', 'enabled' => false ), + 'bad' => 'scalar', + ), + 'disabled_builtins' => array(), + ); + update_option( ExeLearning_Styles_Service::OPTION_REGISTRY, $seed, false ); + $override = ExeLearning_Styles_Service::build_theme_registry_override(); + $this->assertCount( 1, $override['uploaded'] ); + $this->assertSame( 'on', $override['uploaded'][0]['id'] ); + } + + public function test_allocate_unique_slug_suffixes_around_existing_uploads() { + $zip = $this->make_zip( + array( + 'config.xml' => $this->sample_config_xml( 'duo' ), + 'style.css' => 'x{}', + ) + ); + ExeLearning_Styles_Service::install_from_zip( $zip ); + $this->assertSame( 'duo-2', ExeLearning_Styles_Service::allocate_unique_slug( 'duo' ) ); + wp_delete_file( $zip ); + } + + public function test_install_accepts_a_zip_wrapped_in_a_single_root_folder() { + $zip = $this->make_zip( + array( + 'acme/config.xml' => $this->sample_config_xml( 'acme' ), + 'acme/style.css' => 'body{}', + 'acme/img/bg.png' => 'fake', + ) + ); + $entry = ExeLearning_Styles_Service::install_from_zip( $zip ); + $this->assertIsArray( $entry ); + $dir = ExeLearning_Styles_Service::get_storage_dir() . '/acme'; + $this->assertFileExists( $dir . '/style.css' ); + $this->assertFileExists( $dir . '/img/bg.png' ); + wp_delete_file( $zip ); + } + + public function test_install_rejects_archive_without_any_css() { + $zip = $this->make_zip( + array( + 'config.xml' => $this->sample_config_xml( 'nocss' ), + 'info.md' => 'no css', + ) + ); + $result = ExeLearning_Styles_Service::install_from_zip( $zip ); + $this->assertInstanceOf( 'WP_Error', $result ); + $this->assertSame( 'style_no_css', $result->get_error_code() ); + wp_delete_file( $zip ); + } + + public function test_get_registry_survives_garbage_option_value() { + update_option( ExeLearning_Styles_Service::OPTION_REGISTRY, 'not-an-array', false ); + $r = ExeLearning_Styles_Service::get_registry(); + $this->assertSame( array(), $r['uploaded'] ); + $this->assertSame( array(), $r['disabled_builtins'] ); + } + + public function test_recursive_delete_handles_missing_path_gracefully() { + ExeLearning_Styles_Service::recursive_delete( sys_get_temp_dir() . '/does-not-exist-' . uniqid() ); + $this->assertTrue( true ); + } + + public function test_recursive_delete_removes_nested_files() { + $root = sys_get_temp_dir() . '/deltree-' . uniqid(); + mkdir( $root . '/inner/deep', 0755, true ); + file_put_contents( $root . '/a.txt', 'a' ); + file_put_contents( $root . '/inner/b.txt', 'b' ); + file_put_contents( $root . '/inner/deep/c.txt', 'c' ); + $this->assertDirectoryExists( $root ); + ExeLearning_Styles_Service::recursive_delete( $root ); + $this->assertDirectoryDoesNotExist( $root ); + } + + 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 ); + // Default is "imports allowed" — exercise the block toggle + // explicitly to lock the contract both ways. + ExeLearning_Styles_Service::set_import_blocked( true ); + + $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'] ); + // Even icon-less styles must expose the field so the editor's + // Theme constructor doesn't fall back to its empty default. + $this->assertArrayHasKey( 'icons', $override['uploaded'][0] ); + $this->assertSame( array(), $override['uploaded'][0]['icons'] ); + + // Toggle back to the default and confirm the flag follows. + ExeLearning_Styles_Service::set_import_blocked( false ); + $override = ExeLearning_Styles_Service::build_theme_registry_override(); + $this->assertFalse( $override['blockImportInstall'] ); + + // Disabling an upload 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 ); + } + + public function test_build_theme_registry_override_publishes_icons_from_icons_folder() { + $zip_path = $this->make_zip( + array( + 'config.xml' => $this->sample_config_xml( 'iconic' ), + 'style.css' => 'a{}', + 'icons/activity.png' => 'PNG', + 'icons/alert.svg' => '', + 'icons/photo.JPG' => 'JPEG', + 'icons/readme.txt' => 'ignore', + 'icons/no-extension' => 'ignore', + ) + ); + $this->assertIsArray( ExeLearning_Styles_Service::install_from_zip( $zip_path ) ); + + $override = ExeLearning_Styles_Service::build_theme_registry_override(); + $this->assertCount( 1, $override['uploaded'] ); + $entry = $override['uploaded'][0]; + $this->assertSame( 'iconic', $entry['id'] ); + $this->assertArrayHasKey( 'icons', $entry ); + // scandir + ksort gives us deterministic ordering. + $this->assertSame( array( 'activity', 'alert', 'photo' ), array_keys( $entry['icons'] ) ); + $activity = $entry['icons']['activity']; + $this->assertSame( 'activity', $activity['id'] ); + $this->assertSame( 'activity', $activity['title'] ); + $this->assertSame( 'img', $activity['type'] ); + $this->assertStringEndsWith( '/iconic/icons/activity.png', $activity['value'] ); + $this->assertSame( $entry['url'] . '/icons/activity.png', $activity['value'] ); + + wp_delete_file( $zip_path ); + } + + public function test_scan_uploaded_icons_returns_empty_when_icons_folder_missing() { + $this->assertSame( + array(), + ExeLearning_Styles_Service::scan_uploaded_icons( 'no-such-style', 'http://example.test/styles/no-such-style' ) + ); + } + + public function test_scan_uploaded_icons_url_encodes_filenames_with_spaces() { + $zip_path = $this->make_zip( + array( + 'config.xml' => $this->sample_config_xml( 'spaced' ), + 'style.css' => 'a{}', + 'icons/my activity.png' => 'PNG', + ) + ); + ExeLearning_Styles_Service::install_from_zip( $zip_path ); + + $icons = ExeLearning_Styles_Service::scan_uploaded_icons( 'spaced', 'http://example.test/styles/spaced' ); + $this->assertArrayHasKey( 'my activity', $icons ); + $this->assertSame( + 'http://example.test/styles/spaced/icons/my%20activity.png', + $icons['my activity']['value'] + ); + + 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.' + . ''; + } +}