Skip to content

Conversation

@noruzzamans
Copy link

@noruzzamans noruzzamans commented Jan 14, 2026

Description

This Pull Request introduces the ability to download the currently selected theme as a ZIP file directly from the Appearance > Theme File Editor screen. This feature allows users to easily create a backup or copy of the theme they are editing without needing FTP or file manager access.

Ticket

https://core.trac.wordpress.org/ticket/64439

Acknowledgements

Props to @solankisoftware for the initial patch.
Thanks for the contribution! I noticed the file path in the original patch was slightly incorrect for the development environment (it was missing src/), so I have corrected the path to ensure it applies cleanly to the wordpress-develop repository.

Changes

  • Added a "Download Theme" button below the theme editor form in [src/wp-admin/theme-editor.php]
  • Implemented a secure handler for the download_theme action.
  • Added logic to generate a ZIP archive of the theme using ZipArchive (with a fallback to PclZip for compatibility).
  • Ensures proper permission checks (edit_themes) and nonce verification before processing the download.

Testing Instructions

  1. Navigate to Appearance > Theme File Editor.
  2. Select any theme from the dropdown menu (if multiple themes are installed).
  3. Scroll down to the bottom of the editor area.
  4. Click the newly added Download Theme button.
  5. Verify that a .zip file containing the full theme directory is downloaded successfully.
  6. Extract the ZIP file and confirm that all theme files are present and strictly correspond to the selected theme.

@github-actions
Copy link

Hi @noruzzamans! 👋

Thank you for your contribution to WordPress! 💖

It looks like this is your first pull request to wordpress-develop. Here are a few things to be aware of that may help you out!

No one monitors this repository for new pull requests. Pull requests must be attached to a Trac ticket to be considered for inclusion in WordPress Core. To attach a pull request to a Trac ticket, please include the ticket's full URL in your pull request description.

Pull requests are never merged on GitHub. The WordPress codebase continues to be managed through the SVN repository that this GitHub repository mirrors. Please feel free to open pull requests to work on any contribution you are making.

More information about how GitHub pull requests can be used to contribute to WordPress can be found in the Core Handbook.

Please include automated tests. Including tests in your pull request is one way to help your patch be considered faster. To learn about WordPress' test suites, visit the Automated Testing page in the handbook.

If you have not had a chance, please review the Contribute with Code page in the WordPress Core Handbook.

The Developer Hub also documents the various coding standards that are followed:

Thank you,
The WordPress Project

@github-actions
Copy link

github-actions bot commented Jan 14, 2026

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props noruzzaman, westonruter.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@github-actions
Copy link

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • The Plugin and Theme Directories cannot be accessed within Playground.
  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

Updates the downloaded zip filename to follow the 'slug.version.zip' format (e.g., 'twentytwentyfour.1.0.zip'), aligning with WordPress standards and improving file organization.
Fixes the directory structure within the downloaded zip file. Previously, files were added to the root of the zip. This change ensures all files are nested within a folder named after the theme slug (stylesheet), adhering to standard theme packaging conventions.
noruzzamans and others added 5 commits January 15, 2026 10:16
- Replace inline style on download form with CSS class.
- Restore newline at end of file.
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This pull request introduces a theme download feature to the WordPress Theme File Editor, allowing users to download the currently selected theme as a ZIP archive. The implementation provides both a UI button and a secure backend handler.

Changes:

  • Added a "Download Theme" button below the theme editor form
  • Implemented secure backend logic to create ZIP archives using ZipArchive (with PclZip fallback)
  • Added minimal styling for the download form

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 9 comments.

File Description
src/wp-admin/theme-editor.php Added download handler with permission checks, nonce verification, and ZIP generation logic; added download form UI
src/wp-admin/css/common.css Added minimal styling for the download theme form

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@noruzzamans
Copy link
Author

@westonruter Thanks for the review. I have addressed the feedback:

  1. Updated the file iteration logic to use RecursiveIteratorIterator::LEAVES_ONLY and FilesystemIterator::SKIP_DOTS to correctly skip dot directories.
  2. Added sanitize_file_name() to the ZIP filename in the Content-Disposition header.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 7 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +119 to +121
$file_path = $file->getRealPath();
$relative_path = substr( $file_path, strlen( $theme_dir ) + 1 );
$zip->addFile( $file_path, $stylesheet . '/' . $relative_path );
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.
Comment on lines +112 to +123
$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 );
}

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.
}

$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.
Comment on lines +106 to +143
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 );
}

$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();
}

$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>' );
}
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.
);

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.

// 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.
// 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 );
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.
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