Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/wp-admin/css/common.css
Original file line number Diff line number Diff line change
Expand Up @@ -3572,6 +3572,10 @@ img {
line-height: 180%;
}

.fileedit-sub .download-theme-form {
margin-top: 8px;
}

#file-editor-warning .file-editor-warning-content {
margin: 25px;
}
Expand Down
87 changes: 87 additions & 0 deletions src/wp-admin/theme-editor.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,86 @@
wp_die( __( 'The requested theme does not exist.' ) . ' ' . $theme->errors()->get_error_message() );
}

// Handle theme download action: create a zip of the theme and send it to the browser.
if ( 'download_theme' === $action ) {
if ( ! current_user_can( 'edit_themes' ) ) {
wp_die( '<p>' . __( 'Sorry, you are not allowed to download themes for this site.' ) . '</p>' );
}

// Verify nonce.
if ( ! check_admin_referer( 'download-theme_' . $stylesheet ) ) {
wp_die( '<p>' . __( 'Security check failed.' ) . '</p>' );
}

$theme_dir = $theme->get_stylesheet_directory();

if ( ! is_dir( $theme_dir ) ) {
wp_die( '<p>' . __( 'Theme directory not found.' ) . '</p>' );
}

$zipname = $stylesheet;
$version = $theme->get( 'Version' );
if ( $version ) {
$zipname .= '.' . $version;
}

$tmpfile = get_temp_dir() . DIRECTORY_SEPARATOR . $zipname . '-' . time() . '.zip';

Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For themes with a large number of files or large file sizes, the zip creation process could exceed PHP's default execution time limit, causing the download to fail. Other WordPress operations that handle potentially long-running tasks (like updates and exports) use set_time_limit() to extend the execution time. Consider adding a time limit extension before starting the zip creation process to ensure large themes can be downloaded successfully.

Suggested change
// Attempt to extend execution time to allow large theme archives to be created.
if ( function_exists( 'set_time_limit' ) ) {
@set_time_limit( 300 );
}

Copilot uses AI. Check for mistakes.
// Try native ZipArchive first, fall back to PclZip if needed.
if ( class_exists( 'ZipArchive' ) ) {
$zip = new ZipArchive();
if ( true !== $zip->open( $tmpfile, ZipArchive::CREATE ) ) {
wp_die( '<p>' . __( 'Could not create zip archive.' ) . '</p>' );
}

$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator( $theme_dir, FilesystemIterator::SKIP_DOTS ),
RecursiveIteratorIterator::LEAVES_ONLY
);

foreach ( $files as $file ) {
// With SKIP_DOTS and LEAVES_ONLY, we don't need to check isDir().
$file_path = $file->getRealPath();
$relative_path = substr( $file_path, strlen( $theme_dir ) + 1 );
$zip->addFile( $file_path, $stylesheet . '/' . $relative_path );
Comment on lines +119 to +121
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The use of getRealPath() without checking for failure could allow symlinks to be followed outside the theme directory, creating a security risk. If getRealPath() returns false (which can happen for broken symlinks), this will cause issues. Additionally, symlinks pointing outside the theme directory could be included in the archive. Consider checking the return value of getRealPath() and validating that the resolved path is still within the theme directory using a path containment check (e.g., checking if the resolved path starts with the theme directory path).

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ZipArchive::addFile() method can fail silently if a file is not readable or if there are permission issues, but there's no error checking after each file is added. This means some files could be skipped without any indication to the user. Consider checking the return value of addFile() and either logging errors or tracking the count of successfully added files to ensure the archive is complete.

Suggested change
$zip->addFile( $file_path, $stylesheet . '/' . $relative_path );
if ( true !== $zip->addFile( $file_path, $stylesheet . '/' . $relative_path ) ) {
$zip->close();
// Remove partially created archive.
@unlink( $tmpfile );
wp_die(
'<p>' . sprintf(
/* translators: %s: File path. */
__( 'Could not add file to zip archive: %s' ),
esc_html( $relative_path )
) . '</p>'
);
}

Copilot uses AI. Check for mistakes.
}

Comment on lines +112 to +123
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the ZipArchive creation or file addition fails silently (e.g., due to permissions issues or disk space), the code continues to line 146 without checking if files were actually added to the archive. This could result in downloading an empty or incomplete zip file. Consider checking the number of files added to the archive or validating the zip file size before sending it to the user.

Suggested change
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator( $theme_dir, FilesystemIterator::SKIP_DOTS ),
RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ( $files as $file ) {
// With SKIP_DOTS and LEAVES_ONLY, we don't need to check isDir().
$file_path = $file->getRealPath();
$relative_path = substr( $file_path, strlen( $theme_dir ) + 1 );
$zip->addFile( $file_path, $stylesheet . '/' . $relative_path );
}
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator( $theme_dir, FilesystemIterator::SKIP_DOTS ),
RecursiveIteratorIterator::LEAVES_ONLY
);
$files_added = 0;
foreach ( $files as $file ) {
// With SKIP_DOTS and LEAVES_ONLY, we don't need to check isDir().
$file_path = $file->getRealPath();
$relative_path = substr( $file_path, strlen( $theme_dir ) + 1 );
if ( ! $zip->addFile( $file_path, $stylesheet . '/' . $relative_path ) ) {
$zip->close();
wp_die( '<p>' . __( 'Could not create zip archive.' ) . '</p>' );
}
$files_added++;
}
if ( 0 === $files_added ) {
$zip->close();
wp_die( '<p>' . __( 'Could not create zip archive.' ) . '</p>' );
}

Copilot uses AI. Check for mistakes.
$zip->close();
} else {
// Use PclZip fallback bundled with WordPress admin.
require_once ABSPATH . 'wp-admin/includes/class-pclzip.php';

$filelist = array();
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator( $theme_dir, FilesystemIterator::SKIP_DOTS ),
RecursiveIteratorIterator::LEAVES_ONLY
);

foreach ( $files as $file ) {
$filelist[] = $file->getRealPath();
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The use of getRealPath() without checking for failure could cause issues if it returns false (which can happen for broken symlinks). Additionally, symlinks pointing outside the theme directory could be included in the archive, creating a security risk. Consider checking the return value of getRealPath() and validating that the resolved path is still within the theme directory using a path containment check.

Copilot uses AI. Check for mistakes.
}

$archive = new PclZip( $tmpfile );
$result = $archive->create( $filelist, PCLZIP_OPT_REMOVE_PATH, $theme_dir, PCLZIP_OPT_ADD_PATH, $stylesheet );
if ( 0 === $result ) {
wp_die( '<p>' . __( 'Could not create zip archive.' ) . ' ' . esc_html( $archive->errorInfo( true ) ) . '</p>' );
}
Comment on lines +106 to +143
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If an error occurs during zip creation (lines 108-143), the temporary file may be left on the server because there's no cleanup in the error paths. The wp_die() calls at lines 109 and 142 exit without deleting the partially created zip file. Consider adding cleanup code before wp_die() calls or using a try-finally pattern to ensure temporary files are always removed, even on error.

Copilot uses AI. Check for mistakes.
}

if ( ! file_exists( $tmpfile ) ) {
wp_die( '<p>' . __( 'Failed to create theme archive.' ) . '</p>' );
}

// Send the file to the browser.
header( 'Content-Type: application/zip' );
header( 'Content-Disposition: attachment; filename="' . sanitize_file_name( $zipname ) . '.zip"' );
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Content-Disposition header is missing proper quotes escaping. While sanitize_file_name() removes most problematic characters, the filename is already wrapped in double quotes in the header. If the sanitized filename contains a quote character or other special characters that survive sanitization, it could break the header format. Consider using RFC 6266 compliant filename encoding or additional escaping to ensure the header is properly formatted.

Suggested change
header( 'Content-Disposition: attachment; filename="' . sanitize_file_name( $zipname ) . '.zip"' );
$download_filename = sanitize_file_name( $zipname ) . '.zip';
$header_filename = str_replace( array( '\\\\', '"' ), array( '\\\\\\\\', '\\"' ), $download_filename );
header( 'Content-Disposition: attachment; filename="' . $header_filename . '"' );

Copilot uses AI. Check for mistakes.
header( 'Content-Length: ' . filesize( $tmpfile ) );
readfile( $tmpfile );
// Best-effort cleanup of the temporary archive; failure to delete is non-critical.
@unlink( $tmpfile );
exit;
}

$allowed_files = array();
$style_files = array();

Expand Down Expand Up @@ -415,6 +495,13 @@

<?php wp_print_file_editor_templates(); ?>
</form>

<form action="theme-editor.php" method="post" class="download-theme-form">
<?php wp_nonce_field( 'download-theme_' . $stylesheet ); ?>
<input type="hidden" name="action" value="download_theme" />
<input type="hidden" name="theme" value="<?php echo esc_attr( $stylesheet ); ?>" />
<?php submit_button( __( 'Download Theme' ), 'secondary', '', false ); ?>
</form>
<?php
endif; // End if $error.
?>
Expand Down
Loading