Skip to content

feat(styles): administrator-managed style registry for the embedded editor#24

Open
erseco wants to merge 13 commits intomainfrom
feature/allow-installation-and-management-of-styles
Open

feat(styles): administrator-managed style registry for the embedded editor#24
erseco wants to merge 13 commits intomainfrom
feature/allow-installation-and-management-of-styles

Conversation

@erseco
Copy link
Copy Markdown
Contributor

@erseco erseco commented Apr 23, 2026

Summary

Adds a Settings → eXeLearning → Styles section where administrators
can upload eXeLearning style .zip packages, enable/disable built-in
styles 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_registry option,
    unique slug allocation on collisions, build_theme_registry_override().
  • admin/class-admin-styles.php — nonce-protected admin-ajax endpoints
    for upload/toggle/delete, gated on manage_options.
  • admin/class-admin-settings.php — new Styles section in the settings
    page with upload form, built-in list, uploaded list.
  • admin/views/editor-bootstrap.php — injects the approved registry
    into the page before the static editor boots.
  • tests/unit/StylesServiceTest.php — unit tests covering validator
    edge 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 of
the editor install directory, so reinstalling the embedded editor never
destroys admin-managed styles.

Backward compatibility

  • Existing installs continue to work without any admin action; the
    registry is empty by default and the editor sees all built-ins.
  • Projects referencing a disabled/deleted style fall back to the editor
    default (base).
  • The ZIP max size defaults to 20 MB and is filterable via
    exelearning_styles_max_zip_size for sites with large packages or
    tight upload limits.

Test plan

  • php -l passes on all modified files.
  • Isolated smoke run of the validator + registry math (7 assertions).
  • make test FILE=tests/unit/StylesServiceTest.php in a Docker
    wp-env environment (local env is in Playground mode, so the
    Docker test run needs to be done in CI / by the reviewer).
  • Manual verification in wp-env: upload a style ZIP, confirm it
    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 base without errors.

Depends on

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 23, 2026

Test in WordPress Playground

Test the plugin with the code from this branch:

Preview in WordPress Playground

⚠️ The embedded eXeLearning editor is not included in this preview. You can install it from Settings > eXeLearning using the "Download & Install Editor" button. All other plugin features (ELP upload, shortcode, Gutenberg block, preview) work normally.

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 thread includes/class-styles-service.php Fixed
Comment thread includes/class-styles-service.php Fixed
Comment thread includes/class-styles-service.php Fixed
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
}
}
erseco added 13 commits April 24, 2026 00:19
…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.
@erseco erseco force-pushed the feature/allow-installation-and-management-of-styles branch from 5d9cc86 to 6606cef Compare April 23, 2026 23:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants