feat(styles): administrator-managed style registry for the embedded editor#24
Open
feat(styles): administrator-managed style registry for the embedded editor#24
Conversation
Contributor
Test in WordPress PlaygroundTest the plugin with the code from this branch:
|
Comment on lines
+80
to
+293
| private function render_styles_section() { | ||
| $builtins = ExeLearning_Styles_Service::list_builtin_themes(); | ||
| $uploads = ExeLearning_Styles_Service::list_uploaded_styles(); | ||
| $registry = ExeLearning_Styles_Service::get_registry(); | ||
| $disabled_list = $registry['disabled_builtins']; | ||
| $nonce = wp_create_nonce( ExeLearning_Admin_Styles::AJAX_NONCE ); | ||
| $ajax_url = admin_url( 'admin-ajax.php' ); | ||
| $max_size = ExeLearning_Styles_Service::get_max_zip_size(); | ||
| ?> | ||
| <div class="card" id="exelearning-styles-card" style="max-width: 900px; margin-bottom: 20px;"> | ||
| <h2><?php esc_html_e( 'Styles', 'exelearning' ); ?></h2> | ||
| <p class="description"> | ||
| <?php esc_html_e( 'Upload eXeLearning style packages and control which styles the embedded editor exposes.', 'exelearning' ); ?> | ||
| </p> | ||
|
|
||
| <h3><?php esc_html_e( 'Upload a new style', 'exelearning' ); ?></h3> | ||
| <form id="exelearning-styles-upload" enctype="multipart/form-data"> | ||
| <p> | ||
| <input type="file" name="style_zip" accept=".zip,application/zip,application/x-zip-compressed" required /> | ||
| <button type="submit" class="button button-primary"> | ||
| <?php esc_html_e( 'Upload style', 'exelearning' ); ?> | ||
| </button> | ||
| </p> | ||
| <p class="description"> | ||
| <?php | ||
| printf( | ||
| /* translators: %s: human-readable max file size. */ | ||
| esc_html__( 'Maximum file size: %s. Only .zip packages containing a valid config.xml are accepted.', 'exelearning' ), | ||
| esc_html( size_format( $max_size ) ) | ||
| ); | ||
| ?> | ||
| </p> | ||
| </form> | ||
|
|
||
| <div id="exelearning-styles-status" style="display: none; margin: 10px 0;"></div> | ||
|
|
||
| <h3><?php esc_html_e( 'Uploaded styles', 'exelearning' ); ?></h3> | ||
| <?php if ( empty( $uploads ) ) : ?> | ||
| <p><em><?php esc_html_e( 'No uploaded styles yet.', 'exelearning' ); ?></em></p> | ||
| <?php else : ?> | ||
| <table class="widefat striped" id="exelearning-styles-uploaded"> | ||
| <thead> | ||
| <tr> | ||
| <th><?php esc_html_e( 'Title', 'exelearning' ); ?></th> | ||
| <th><?php esc_html_e( 'Id', 'exelearning' ); ?></th> | ||
| <th><?php esc_html_e( 'Version', 'exelearning' ); ?></th> | ||
| <th><?php esc_html_e( 'Installed', 'exelearning' ); ?></th> | ||
| <th><?php esc_html_e( 'Enabled', 'exelearning' ); ?></th> | ||
| <th><?php esc_html_e( 'Actions', 'exelearning' ); ?></th> | ||
| </tr> | ||
| </thead> | ||
| <tbody> | ||
| <?php foreach ( $uploads as $style ) : ?> | ||
| <tr data-slug="<?php echo esc_attr( $style['id'] ); ?>"> | ||
| <td><?php echo esc_html( $style['title'] ); ?></td> | ||
| <td><code><?php echo esc_html( $style['id'] ); ?></code></td> | ||
| <td><?php echo esc_html( $style['version'] ); ?></td> | ||
| <td><?php echo esc_html( $style['installed_at'] ); ?></td> | ||
| <td> | ||
| <label> | ||
| <input type="checkbox" | ||
| class="exelearning-styles-toggle-uploaded" | ||
| <?php checked( ! empty( $style['enabled'] ) ); ?> /> | ||
| <?php esc_html_e( 'Enabled', 'exelearning' ); ?> | ||
| </label> | ||
| </td> | ||
| <td> | ||
| <button type="button" class="button-link-delete exelearning-styles-delete"> | ||
| <?php esc_html_e( 'Delete', 'exelearning' ); ?> | ||
| </button> | ||
| </td> | ||
| </tr> | ||
| <?php endforeach; ?> | ||
| </tbody> | ||
| </table> | ||
| <?php endif; ?> | ||
|
|
||
| <h3><?php esc_html_e( 'Built-in styles', 'exelearning' ); ?></h3> | ||
| <?php if ( empty( $builtins ) ) : ?> | ||
| <p> | ||
| <em><?php esc_html_e( 'Built-in styles are not available because the embedded editor is not installed.', 'exelearning' ); ?></em> | ||
| </p> | ||
| <?php else : ?> | ||
| <table class="widefat striped" id="exelearning-styles-builtins"> | ||
| <thead> | ||
| <tr> | ||
| <th><?php esc_html_e( 'Title', 'exelearning' ); ?></th> | ||
| <th><?php esc_html_e( 'Id', 'exelearning' ); ?></th> | ||
| <th><?php esc_html_e( 'Version', 'exelearning' ); ?></th> | ||
| <th><?php esc_html_e( 'Enabled', 'exelearning' ); ?></th> | ||
| </tr> | ||
| </thead> | ||
| <tbody> | ||
| <?php foreach ( $builtins as $style ) : ?> | ||
| <?php $is_disabled = in_array( $style['id'], $disabled_list, true ); ?> | ||
| <tr data-id="<?php echo esc_attr( $style['id'] ); ?>"> | ||
| <td><?php echo esc_html( $style['title'] ); ?></td> | ||
| <td><code><?php echo esc_html( $style['id'] ); ?></code></td> | ||
| <td><?php echo esc_html( $style['version'] ); ?></td> | ||
| <td> | ||
| <label> | ||
| <input type="checkbox" | ||
| class="exelearning-styles-toggle-builtin" | ||
| <?php checked( ! $is_disabled ); ?> /> | ||
| <?php esc_html_e( 'Enabled', 'exelearning' ); ?> | ||
| </label> | ||
| </td> | ||
| </tr> | ||
| <?php endforeach; ?> | ||
| </tbody> | ||
| </table> | ||
| <?php endif; ?> | ||
|
|
||
| <p class="description"> | ||
| <?php esc_html_e( '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.', 'exelearning' ); ?> | ||
| </p> | ||
| </div> | ||
|
|
||
| <script> | ||
| (function () { | ||
| var ajaxUrl = <?php echo wp_json_encode( $ajax_url ); ?>; | ||
| var nonce = <?php echo wp_json_encode( $nonce ); ?>; | ||
| var statusBox = document.getElementById('exelearning-styles-status'); | ||
|
|
||
| function setStatus(type, message) { | ||
| if (!statusBox) return; | ||
| var cls = type === 'success' ? 'notice-success' : 'notice-error'; | ||
| statusBox.style.display = 'block'; | ||
| statusBox.innerHTML = '<div class="notice ' + cls + ' inline"><p></p></div>'; | ||
| statusBox.querySelector('p').textContent = message; | ||
| } | ||
|
|
||
| function post(formData) { | ||
| formData.append('_ajax_nonce', nonce); | ||
| return fetch(ajaxUrl, { | ||
| method: 'POST', | ||
| body: formData, | ||
| credentials: 'same-origin' | ||
| }).then(function (r) { return r.json(); }); | ||
| } | ||
|
|
||
| var uploadForm = document.getElementById('exelearning-styles-upload'); | ||
| if (uploadForm) { | ||
| uploadForm.addEventListener('submit', function (e) { | ||
| e.preventDefault(); | ||
| var fd = new FormData(uploadForm); | ||
| fd.append('action', 'exelearning_styles_upload'); | ||
| setStatus('info', <?php echo wp_json_encode( __( 'Uploading…', 'exelearning' ) ); ?>); | ||
| post(fd).then(function (resp) { | ||
| if (resp && resp.success) { | ||
| setStatus('success', (resp.data && resp.data.message) || <?php echo wp_json_encode( __( 'Style installed.', 'exelearning' ) ); ?>); | ||
| setTimeout(function () { location.reload(); }, 700); | ||
| } else { | ||
| setStatus('error', (resp && resp.data && resp.data.message) || <?php echo wp_json_encode( __( 'Upload failed.', 'exelearning' ) ); ?>); | ||
| } | ||
| }).catch(function () { | ||
| setStatus('error', <?php echo wp_json_encode( __( 'Network error.', 'exelearning' ) ); ?>); | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| function bindToggle(cls, action, datasetKey) { | ||
| var inputs = document.querySelectorAll('.' + cls); | ||
| for (var i = 0; i < inputs.length; i++) { | ||
| inputs[i].addEventListener('change', function (ev) { | ||
| var cb = ev.target; | ||
| var row = cb.closest('tr'); | ||
| var fd = new FormData(); | ||
| fd.append('action', action); | ||
| fd.append(datasetKey, row.dataset[datasetKey === 'slug' ? 'slug' : 'id']); | ||
| fd.append('enabled', cb.checked ? '1' : ''); | ||
| post(fd).then(function (resp) { | ||
| if (!resp || !resp.success) { | ||
| cb.checked = !cb.checked; | ||
| setStatus('error', (resp && resp.data && resp.data.message) || <?php echo wp_json_encode( __( 'Update failed.', 'exelearning' ) ); ?>); | ||
| } | ||
| }).catch(function () { | ||
| cb.checked = !cb.checked; | ||
| setStatus('error', <?php echo wp_json_encode( __( 'Network error.', 'exelearning' ) ); ?>); | ||
| }); | ||
| }); | ||
| } | ||
| } | ||
| bindToggle('exelearning-styles-toggle-uploaded', 'exelearning_styles_toggle_uploaded', 'slug'); | ||
| bindToggle('exelearning-styles-toggle-builtin', 'exelearning_styles_toggle_builtin', 'id'); | ||
|
|
||
| var deletes = document.querySelectorAll('.exelearning-styles-delete'); | ||
| for (var i = 0; i < deletes.length; i++) { | ||
| deletes[i].addEventListener('click', function (ev) { | ||
| var btn = ev.target; | ||
| var row = btn.closest('tr'); | ||
| var slug = row.dataset.slug; | ||
| if (!window.confirm(<?php echo wp_json_encode( __( 'Delete this style? This cannot be undone.', 'exelearning' ) ); ?>)) { | ||
| return; | ||
| } | ||
| var fd = new FormData(); | ||
| fd.append('action', 'exelearning_styles_delete'); | ||
| fd.append('slug', slug); | ||
| post(fd).then(function (resp) { | ||
| if (resp && resp.success) { | ||
| row.parentNode.removeChild(row); | ||
| setStatus('success', <?php echo wp_json_encode( __( 'Style deleted.', 'exelearning' ) ); ?>); | ||
| } else { | ||
| setStatus('error', (resp && resp.data && resp.data.message) || <?php echo wp_json_encode( __( 'Delete failed.', 'exelearning' ) ); ?>); | ||
| } | ||
| }).catch(function () { | ||
| setStatus('error', <?php echo wp_json_encode( __( 'Network error.', 'exelearning' ) ); ?>); | ||
| }); | ||
| }); | ||
| } | ||
| })(); | ||
| </script> | ||
| <?php | ||
| } |
Comment on lines
+297
to
+358
| 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; | ||
| } |
Comment on lines
+370
to
+488
| 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, | ||
| ); | ||
| } |
Comment on lines
+370
to
+488
| 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, | ||
| ); | ||
| } |
Comment on lines
+496
to
+532
| 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 <name> 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 : '', | ||
| ); | ||
| } |
Comment on lines
+542
to
+602
| 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; | ||
| } |
Comment on lines
+542
to
+602
| 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; | ||
| } |
Comment on lines
+165
to
+191
| 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; | ||
| } |
Comment on lines
+28
to
+787
| class ExeLearning_Styles_Service { | ||
|
|
||
| const OPTION_REGISTRY = 'exelearning_styles_registry'; | ||
| const OPTION_BLOCK_IMPORT = 'exelearning_styles_block_import'; | ||
| const UPLOAD_SUBDIR = 'exelearning-styles'; | ||
| const DEFAULT_MAX_ZIP_SIZE = 20971520; // 20 MB. | ||
|
|
||
| /** | ||
| * File extensions allowed inside a style ZIP. | ||
| * | ||
| * @var string[] | ||
| */ | ||
| const ALLOWED_EXTENSIONS = array( | ||
| 'css', | ||
| 'js', | ||
| 'map', | ||
| 'svg', | ||
| 'png', | ||
| 'jpg', | ||
| 'jpeg', | ||
| 'gif', | ||
| 'webp', | ||
| 'ico', | ||
| 'xml', | ||
| 'json', | ||
| 'md', | ||
| 'txt', | ||
| 'html', | ||
| 'htm', | ||
| 'woff', | ||
| 'woff2', | ||
| 'ttf', | ||
| 'otf', | ||
| 'eot', | ||
| ); | ||
|
|
||
| /** | ||
| * Absolute path to the directory that stores uploaded style bundles. | ||
| * | ||
| * @return string | ||
| */ | ||
| public static function get_storage_dir() { | ||
| $upload = wp_upload_dir(); | ||
| return trailingslashit( $upload['basedir'] ) . self::UPLOAD_SUBDIR; | ||
| } | ||
|
|
||
| /** | ||
| * Public URL that maps to {@see self::get_storage_dir()}. | ||
| * | ||
| * @return string | ||
| */ | ||
| public static function get_storage_url() { | ||
| $upload = wp_upload_dir(); | ||
| return trailingslashit( $upload['baseurl'] ) . self::UPLOAD_SUBDIR; | ||
| } | ||
|
|
||
| /** | ||
| * Maximum allowed size for an uploaded style ZIP, in bytes. | ||
| * | ||
| * Filterable via `exelearning_styles_max_zip_size` for sites with tight | ||
| * upload limits or large theme bundles. | ||
| * | ||
| * @return int | ||
| */ | ||
| public static function get_max_zip_size() { | ||
| /** | ||
| * Filter the maximum uploaded style ZIP size. | ||
| * | ||
| * @param int $size Size in bytes. | ||
| */ | ||
| $size = (int) apply_filters( 'exelearning_styles_max_zip_size', self::DEFAULT_MAX_ZIP_SIZE ); | ||
| return $size > 0 ? $size : self::DEFAULT_MAX_ZIP_SIZE; | ||
| } | ||
|
|
||
| /** | ||
| * Load the persisted registry as an associative array. | ||
| * | ||
| * @return array{uploaded: array<string,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<int, array<string,mixed>> | ||
| */ | ||
| 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<int, array<string,mixed>> | ||
| */ | ||
| 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<int, array<string,mixed>> | ||
| */ | ||
| 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<int, array<string,mixed>>, 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' => 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 true; | ||
| } | ||
| 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<string,string>, 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<string,string>|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 <name> 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 | ||
| } | ||
| } |
…ditor 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/<slug>/` — 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.
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.
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.
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.
Exposes a 'Block user-imported styles' checkbox on the Styles admin section. When enabled (the default), the editor bootstrap sets: - window.eXeLearning.config.themeRegistryOverride.blockImportInstall=true - window.eXeLearning.config.userStyles = 0 The first prevents any install path in our plugin layer. The second tells the core editor to suppress its 'Import this project style?' modal on project open (the pre-existing ONLINE_THEMES_INSTALL contract). A companion core-editor PR (exelearning#1724) hides the 'Imported' tab entirely when either flag is set.
… handlers Fixes the CI PHPCS run by documenting intentional deprecated/non-WP calls, covering the ZipArchive::$numFiles property access, aligning assignment blocks, annotating nonce-verified $_POST reads, and adding strict boolean sanitization plus the missing ajax_delete doc comment.
…le manager Adds the Spanish translations (and matching POT entries) for the "Import policy" / "Block user-imported styles" section added to the settings page so composer check-untranslated keeps passing.
Matches upstream eXeLearning ONLINE_THEMES_INSTALL=true, so existing installs and new ones preserve the familiar behavior (imports allowed). Admins now opt-in to the lockdown from the Styles page instead of being opted in silently.
Three integration fixes discovered while running the editor end-to-end:
- Trap reassignments of window.eXeLearning and window.eXeLearning.config.
The static editor's boot sequence reassigns both (the inline script in
index.html resets the whole object, and app.bundle.js later parses
'config' from JSON back into an object). Wrap our injection in a
self-restoring defineProperty getter/setter so the themeRegistryOverride
and userStyles mirror survive every reset.
- Strip a second http(s):// inside asset URLs. The editor always computes
a theme asset path as 'symfonyURL + theme.url'. Admin-uploaded styles
live at an absolute URL ('/wp-content/uploads/...'), so the naive
concatenation produces '<editorBaseUrl><absolute>'. Detect a second
scheme in the URL and use the absolute URL verbatim.
- Switch the uploaded-style type from 'uploaded' to 'admin' in the
registry payload. The core editor special-cases 'uploaded' as a
user-imported theme; 'admin' keeps them alongside built-ins in the
'System' tab, which is what we want for admin-approved styles.
build_theme_registry_override() now ships a 'files' array alongside each uploaded entry, enumerated from the extracted storage directory. The embedded editor's ResourceFetcher reads this manifest to pull admin-uploaded styles file by file instead of looking for a zip in bundles/themes/, so preview and HTML5 export pack the real theme assets (CSS, fonts, icons) under theme/* rather than falling back to the placeholder theme/content.css + theme/default.js.
- is_unsafe_zip_entry: every positive-rejection branch had been flipped to 'return false', so the validator silently accepted path traversal, absolute paths, backslashes, stream schemes and empty entries. Flip the returns back to 'true'. Expose the helper as public so the matrix is testable without going through ZipArchive (which normalizes leading '../' away on some builds). - tests/unit/AdminStylesTest.php: full coverage for the AJAX handler — permission guards (manage_options + nonce), missing slug/id/file, upload/toggle/delete/block-import round trips. Uses the WPDieException pattern established in StaticEditorInstallerTest. - tests/unit/StylesServiceTest.php: port the Omeka-S edge-case matrix — error paths (missing/empty/non-zip files, multi-config archive, invalid XML, missing name), registry garbage-JSON survival, list_uploaded_styles skipping scalar entries, allocate_unique_slug with collisions, single-root-folder install, archive without any CSS, is_unsafe_zip_entry matrix, recursive_delete on missing path, is_import_blocked default toggle, normalize_slug edge cases. - test_build_theme_registry_override_respects_enabled_flag now exercises the block-import toggle in both directions (default is OFF, matching upstream).
The fatal blew up the entire test file before any case could run: parent signature is ($message, $title, $args). Match it.
wp-env runs under the site's locale; assertions like 'Insufficient permissions' blew up on a Spanish install. Assert success=false and a non-empty message instead — the semantic contract is the same.
5d9cc86 to
6606cef
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a Settings → eXeLearning → Styles section where administrators
can upload eXeLearning style
.zippackages, enable/disable built-instyles individually, and enable/disable/delete uploaded styles — all
without rebuilding the bundled static editor or editing
dist/static.Consumed by the runtime hook added upstream in
exelearning/exelearning#1722 — this plugin injects the approved registry
into the editor bootstrap as
window.eXeLearning.config.themeRegistryOverride.Changes
includes/class-styles-service.php— pure logic: ZIP validator(traversal / absolute paths / size cap / extension allow-list),
registry persistence in the
exelearning_styles_registryoption,unique slug allocation on collisions,
build_theme_registry_override().admin/class-admin-styles.php— nonce-protected admin-ajax endpointsfor upload/toggle/delete, gated on
manage_options.admin/class-admin-settings.php— new Styles section in the settingspage with upload form, built-in list, uploaded list.
admin/views/editor-bootstrap.php— injects the approved registryinto the page before the static editor boots.
tests/unit/StylesServiceTest.php— unit tests covering validatoredge cases, install, slug collisions, delete, and the override shape.
README.md— short "Managing styles" section.Storage
Uploaded style bundles extract to
wp-content/uploads/exelearning-styles/<slug>/. This is a sibling ofthe editor install directory, so reinstalling the embedded editor never
destroys admin-managed styles.
Backward compatibility
registry is empty by default and the editor sees all built-ins.
default (
base).exelearning_styles_max_zip_sizefor sites with large packages ortight upload limits.
Test plan
php -lpasses on all modified files.make test FILE=tests/unit/StylesServiceTest.phpin a Dockerwp-env environment (local env is in Playground mode, so the
Docker test run needs to be done in CI / by the reviewer).
appears in the editor's style selector; toggle a built-in,
confirm it disappears; open an ELPX that references a deleted
style and confirm it falls back to
basewithout errors.Depends on
themeRegistryOverrideruntime hook). Until that PR is merged and this plugin bumps to a
bundled editor that contains it, the override is silently ignored —
behavior is unchanged.