diff -r 7b1b88e27a20 -r 48c4eec2b7e6 wp/wp-admin/includes/class-wp-upgrader.php --- a/wp/wp-admin/includes/class-wp-upgrader.php Thu Sep 29 08:06:27 2022 +0200 +++ b/wp/wp-admin/includes/class-wp-upgrader.php Fri Sep 05 18:40:08 2025 +0200 @@ -48,6 +48,7 @@ * * @since 2.8.0 */ +#[AllowDynamicProperties] class WP_Upgrader { /** @@ -112,6 +113,26 @@ public $update_current = 0; /** + * Stores the list of plugins or themes added to temporary backup directory. + * + * Used by the rollback functions. + * + * @since 6.3.0 + * @var array + */ + private $temp_backups = array(); + + /** + * Stores the list of plugins or themes to be restored from temporary backup directory. + * + * Used by the rollback functions. + * + * @since 6.3.0 + * @var array + */ + private $temp_restores = array(); + + /** * Construct the upgrader with a skin. * * @since 2.8.0 @@ -128,34 +149,54 @@ } /** - * Initialize the upgrader. + * Initializes the upgrader. * * This will set the relationship between the skin being used and this upgrader, * and also add the generic strings to `WP_Upgrader::$strings`. * + * Additionally, it will schedule a weekly task to clean up the temporary backup directory. + * * @since 2.8.0 + * @since 6.3.0 Added the `schedule_temp_backup_cleanup()` task. */ public function init() { $this->skin->set_upgrader( $this ); $this->generic_strings(); + + if ( ! wp_installing() ) { + $this->schedule_temp_backup_cleanup(); + } } /** - * Add the generic strings to WP_Upgrader::$strings. + * Schedules the cleanup of the temporary backup directory. + * + * @since 6.3.0 + */ + protected function schedule_temp_backup_cleanup() { + if ( false === wp_next_scheduled( 'wp_delete_temp_updater_backups' ) ) { + wp_schedule_event( time(), 'weekly', 'wp_delete_temp_updater_backups' ); + } + } + + /** + * Adds the generic strings to WP_Upgrader::$strings. * * @since 2.8.0 */ public function generic_strings() { - $this->strings['bad_request'] = __( 'Invalid data provided.' ); - $this->strings['fs_unavailable'] = __( 'Could not access filesystem.' ); - $this->strings['fs_error'] = __( 'Filesystem error.' ); - $this->strings['fs_no_root_dir'] = __( 'Unable to locate WordPress root directory.' ); - $this->strings['fs_no_content_dir'] = __( 'Unable to locate WordPress content directory (wp-content).' ); + $this->strings['bad_request'] = __( 'Invalid data provided.' ); + $this->strings['fs_unavailable'] = __( 'Could not access filesystem.' ); + $this->strings['fs_error'] = __( 'Filesystem error.' ); + $this->strings['fs_no_root_dir'] = __( 'Unable to locate WordPress root directory.' ); + /* translators: %s: Directory name. */ + $this->strings['fs_no_content_dir'] = sprintf( __( 'Unable to locate WordPress content directory (%s).' ), 'wp-content' ); $this->strings['fs_no_plugins_dir'] = __( 'Unable to locate WordPress plugin directory.' ); $this->strings['fs_no_themes_dir'] = __( 'Unable to locate WordPress theme directory.' ); /* translators: %s: Directory name. */ $this->strings['fs_no_folder'] = __( 'Unable to locate needed folder (%s).' ); + $this->strings['no_package'] = __( 'Package not available.' ); $this->strings['download_failed'] = __( 'Download failed.' ); $this->strings['installing_package'] = __( 'Installing the latest version…' ); $this->strings['no_files'] = __( 'The package contains no files.' ); @@ -166,10 +207,19 @@ $this->strings['maintenance_start'] = __( 'Enabling Maintenance mode…' ); $this->strings['maintenance_end'] = __( 'Disabling Maintenance mode…' ); + + /* translators: %s: upgrade-temp-backup */ + $this->strings['temp_backup_mkdir_failed'] = sprintf( __( 'Could not create the %s directory.' ), 'upgrade-temp-backup' ); + /* translators: %s: upgrade-temp-backup */ + $this->strings['temp_backup_move_failed'] = sprintf( __( 'Could not move the old version to the %s directory.' ), 'upgrade-temp-backup' ); + /* translators: %s: The plugin or theme slug. */ + $this->strings['temp_backup_restore_failed'] = __( 'Could not restore the original version of %s.' ); + /* translators: %s: The plugin or theme slug. */ + $this->strings['temp_backup_delete_failed'] = __( 'Could not delete the temporary backup directory for %s.' ); } /** - * Connect to the filesystem. + * Connects to the filesystem. * * @since 2.8.0 * @@ -241,7 +291,7 @@ } /** - * Download a package. + * Downloads a package. * * @since 2.8.0 * @since 5.2.0 Added the `$check_signatures` parameter. @@ -291,7 +341,7 @@ } /** - * Unpack a compressed package file. + * Unpacks a compressed package file. * * @since 2.8.0 * @@ -307,6 +357,10 @@ $this->skin->feedback( 'unpack_package' ); + if ( ! $wp_filesystem->wp_content_dir() ) { + return new WP_Error( 'fs_no_content_dir', $this->strings['fs_no_content_dir'] ); + } + $upgrade_folder = $wp_filesystem->wp_content_dir() . 'upgrade/'; // Clean up contents of upgrade directory beforehand. @@ -345,7 +399,7 @@ } /** - * Flatten the results of WP_Filesystem_Base::dirlist() for iterating over. + * Flattens the results of WP_Filesystem_Base::dirlist() for iterating over. * * @since 4.9.0 * @access protected @@ -428,6 +482,7 @@ * clear out the destination folder if it already exists. * * @since 2.8.0 + * @since 6.2.0 Use move_dir() instead of copy_dir() when possible. * * @global WP_Filesystem_Base $wp_filesystem WordPress filesystem subclass. * @global array $wp_theme_directories @@ -469,9 +524,14 @@ $destination = $args['destination']; $clear_destination = $args['clear_destination']; - set_time_limit( 300 ); + if ( function_exists( 'set_time_limit' ) ) { + set_time_limit( 300 ); + } - if ( empty( $source ) || empty( $destination ) ) { + if ( + ( ! is_string( $source ) || '' === $source || trim( $source ) !== $source ) || + ( ! is_string( $destination ) || '' === $destination || trim( $destination ) !== $destination ) + ) { return new WP_Error( 'bad_request', $this->strings['bad_request'] ); } $this->skin->feedback( 'installing_package' ); @@ -508,8 +568,10 @@ // There are no files? return new WP_Error( 'incompatible_archive_empty', $this->strings['incompatible_archive'], $this->strings['no_files'] ); } else { - // It's only a single file, the upgrader will use the folder name of this file as the destination folder. - // Folder name is based on zip filename. + /* + * It's only a single file, the upgrader will use the folder name of this file as the destination folder. + * Folder name is based on zip filename. + */ $source = trailingslashit( $args['source'] ); } @@ -530,6 +592,16 @@ return $source; } + if ( ! empty( $args['hook_extra']['temp_backup'] ) ) { + $temp_backup = $this->move_to_temp_backup_dir( $args['hook_extra']['temp_backup'] ); + + if ( is_wp_error( $temp_backup ) ) { + return $temp_backup; + } + + $this->temp_backups[] = $args['hook_extra']['temp_backup']; + } + // Has the source location changed? If so, we need a new source_files list. if ( $source !== $remote_source ) { $source_files = array_keys( $wp_filesystem->dirlist( $source ) ); @@ -576,8 +648,10 @@ return $removed; } } elseif ( $args['abort_if_destination_exists'] && $wp_filesystem->exists( $remote_destination ) ) { - // If we're not clearing the destination folder and something exists there already, bail. - // But first check to see if there are actually any files in the folder. + /* + * If we're not clearing the destination folder and something exists there already, bail. + * But first check to see if there are actually any files in the folder. + */ $_files = $wp_filesystem->dirlist( $remote_destination ); if ( ! empty( $_files ) ) { $wp_filesystem->delete( $remote_source, true ); // Clear out the source files. @@ -585,25 +659,38 @@ } } - // Create destination if needed. - if ( ! $wp_filesystem->exists( $remote_destination ) ) { - if ( ! $wp_filesystem->mkdir( $remote_destination, FS_CHMOD_DIR ) ) { - return new WP_Error( 'mkdir_failed_destination', $this->strings['mkdir_failed'], $remote_destination ); + /* + * If 'clear_working' is false, the source should not be removed, so use copy_dir() instead. + * + * Partial updates, like language packs, may want to retain the destination. + * If the destination exists or has contents, this may be a partial update, + * and the destination should not be removed, so use copy_dir() instead. + */ + if ( $args['clear_working'] + && ( + // Destination does not exist or has no contents. + ! $wp_filesystem->exists( $remote_destination ) + || empty( $wp_filesystem->dirlist( $remote_destination ) ) + ) + ) { + $result = move_dir( $source, $remote_destination, true ); + } else { + // Create destination if needed. + if ( ! $wp_filesystem->exists( $remote_destination ) ) { + if ( ! $wp_filesystem->mkdir( $remote_destination, FS_CHMOD_DIR ) ) { + return new WP_Error( 'mkdir_failed_destination', $this->strings['mkdir_failed'], $remote_destination ); + } } + $result = copy_dir( $source, $remote_destination ); } - // Copy new version of item into place. - $result = copy_dir( $source, $remote_destination ); - if ( is_wp_error( $result ) ) { - if ( $args['clear_working'] ) { - $wp_filesystem->delete( $remote_source, true ); - } - return $result; + // Clear the working directory? + if ( $args['clear_working'] ) { + $wp_filesystem->delete( $remote_source, true ); } - // Clear the working folder? - if ( $args['clear_working'] ) { - $wp_filesystem->delete( $remote_source, true ); + if ( is_wp_error( $result ) ) { + return $result; } $destination_name = basename( str_replace( $local_destination, '', $destination ) ); @@ -634,7 +721,7 @@ } /** - * Run an upgrade/installation. + * Runs an upgrade/installation. * * Attempts to download the package (if it is not a local file), unpack it, and * install it in the destination folder. @@ -741,10 +828,12 @@ * Download the package. Note: If the package is the full path * to an existing local file, it will be returned untouched. */ - $download = $this->download_package( $options['package'], true, $options['hook_extra'] ); + $download = $this->download_package( $options['package'], false, $options['hook_extra'] ); - // Allow for signature soft-fail. - // WARNING: This may be removed in the future. + /* + * Allow for signature soft-fail. + * WARNING: This may be removed in the future. + */ if ( is_wp_error( $download ) && $download->get_error_data( 'softfail-filename' ) ) { // Don't output the 'no signature could be found' failure message for now. @@ -810,7 +899,24 @@ $result = apply_filters( 'upgrader_install_package_result', $result, $options['hook_extra'] ); $this->skin->set_result( $result ); + if ( is_wp_error( $result ) ) { + // An automatic plugin update will have already performed its rollback. + if ( ! empty( $options['hook_extra']['temp_backup'] ) ) { + $this->temp_restores[] = $options['hook_extra']['temp_backup']; + + /* + * Restore the backup on shutdown. + * Actions running on `shutdown` are immune to PHP timeouts, + * so in case the failure was due to a PHP timeout, + * it will still be able to properly restore the previous version. + * + * Zero arguments are accepted as a string can sometimes be passed + * internally during actions, causing an error because + * `WP_Upgrader::restore_temp_backup()` expects an array. + */ + add_action( 'shutdown', array( $this, 'restore_temp_backup' ), 10, 0 ); + } $this->skin->error( $result ); if ( ! method_exists( $this->skin, 'hide_process_failed' ) || ! $this->skin->hide_process_failed( $result ) ) { @@ -823,6 +929,12 @@ $this->skin->after(); + // Clean up the backup kept in the temporary backup directory. + if ( ! empty( $options['hook_extra']['temp_backup'] ) ) { + // Delete the backup on `shutdown` to avoid a PHP timeout. + add_action( 'shutdown', array( $this, 'delete_temp_backup' ), 100, 0 ); + } + if ( ! $options['is_multi'] ) { /** @@ -864,7 +976,7 @@ } /** - * Toggle maintenance mode for the site. + * Toggles maintenance mode for the site. * * Creates/deletes the maintenance file to enable/disable maintenance mode. * @@ -876,15 +988,25 @@ */ public function maintenance_mode( $enable = false ) { global $wp_filesystem; + + if ( ! $wp_filesystem ) { + require_once ABSPATH . 'wp-admin/includes/file.php'; + WP_Filesystem(); + } + $file = $wp_filesystem->abspath() . '.maintenance'; if ( $enable ) { - $this->skin->feedback( 'maintenance_start' ); + if ( ! wp_doing_cron() ) { + $this->skin->feedback( 'maintenance_start' ); + } // Create maintenance file to signal that we are upgrading. $maintenance_string = ''; $wp_filesystem->delete( $file ); $wp_filesystem->put_contents( $file, $maintenance_string, FS_CHMOD_FILE ); } elseif ( ! $enable && $wp_filesystem->exists( $file ) ) { - $this->skin->feedback( 'maintenance_end' ); + if ( ! wp_doing_cron() ) { + $this->skin->feedback( 'maintenance_end' ); + } $wp_filesystem->delete( $file ); } } @@ -894,6 +1016,8 @@ * * @since 4.5.0 * + * @global wpdb $wpdb The WordPress database abstraction object. + * * @param string $lock_name The name of this unique lock. * @param int $release_timeout Optional. The duration in seconds to respect an existing lock. * Default: 1 hour. @@ -907,7 +1031,7 @@ $lock_option = $lock_name . '.lock'; // Try to lock. - $lock_result = $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO `$wpdb->options` ( `option_name`, `option_value`, `autoload` ) VALUES (%s, %s, 'no') /* LOCK */", $lock_option, time() ) ); + $lock_result = $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO `$wpdb->options` ( `option_name`, `option_value`, `autoload` ) VALUES (%s, %s, 'off') /* LOCK */", $lock_option, time() ) ); if ( ! $lock_result ) { $lock_result = get_option( $lock_option ); @@ -947,6 +1071,201 @@ public static function release_lock( $lock_name ) { return delete_option( $lock_name . '.lock' ); } + + /** + * Moves the plugin or theme being updated into a temporary backup directory. + * + * @since 6.3.0 + * + * @global WP_Filesystem_Base $wp_filesystem WordPress filesystem subclass. + * + * @param string[] $args { + * Array of data for the temporary backup. + * + * @type string $slug Plugin or theme slug. + * @type string $src Path to the root directory for plugins or themes. + * @type string $dir Destination subdirectory name. Accepts 'plugins' or 'themes'. + * } + * + * @return bool|WP_Error True on success, false on early exit, otherwise WP_Error. + */ + public function move_to_temp_backup_dir( $args ) { + global $wp_filesystem; + + if ( empty( $args['slug'] ) || empty( $args['src'] ) || empty( $args['dir'] ) ) { + return false; + } + + /* + * Skip any plugin that has "." as its slug. + * A slug of "." will result in a `$src` value ending in a period. + * + * On Windows, this will cause the 'plugins' folder to be moved, + * and will cause a failure when attempting to call `mkdir()`. + */ + if ( '.' === $args['slug'] ) { + return false; + } + + if ( ! $wp_filesystem->wp_content_dir() ) { + return new WP_Error( 'fs_no_content_dir', $this->strings['fs_no_content_dir'] ); + } + + $dest_dir = $wp_filesystem->wp_content_dir() . 'upgrade-temp-backup/'; + $sub_dir = $dest_dir . $args['dir'] . '/'; + + // Create the temporary backup directory if it does not exist. + if ( ! $wp_filesystem->is_dir( $sub_dir ) ) { + if ( ! $wp_filesystem->is_dir( $dest_dir ) ) { + $wp_filesystem->mkdir( $dest_dir, FS_CHMOD_DIR ); + } + + if ( ! $wp_filesystem->mkdir( $sub_dir, FS_CHMOD_DIR ) ) { + // Could not create the backup directory. + return new WP_Error( 'fs_temp_backup_mkdir', $this->strings['temp_backup_mkdir_failed'] ); + } + } + + $src_dir = $wp_filesystem->find_folder( $args['src'] ); + $src = trailingslashit( $src_dir ) . $args['slug']; + $dest = $dest_dir . trailingslashit( $args['dir'] ) . $args['slug']; + + // Delete the temporary backup directory if it already exists. + if ( $wp_filesystem->is_dir( $dest ) ) { + $wp_filesystem->delete( $dest, true ); + } + + // Move to the temporary backup directory. + $result = move_dir( $src, $dest, true ); + if ( is_wp_error( $result ) ) { + return new WP_Error( 'fs_temp_backup_move', $this->strings['temp_backup_move_failed'] ); + } + + return true; + } + + /** + * Restores the plugin or theme from temporary backup. + * + * @since 6.3.0 + * @since 6.6.0 Added the `$temp_backups` parameter. + * + * @global WP_Filesystem_Base $wp_filesystem WordPress filesystem subclass. + * + * @param array[] $temp_backups { + * Optional. An array of temporary backups. + * + * @type array ...$0 { + * Information about the backup. + * + * @type string $dir The temporary backup location in the upgrade-temp-backup directory. + * @type string $slug The item's slug. + * @type string $src The directory where the original is stored. For example, `WP_PLUGIN_DIR`. + * } + * } + * @return bool|WP_Error True on success, false on early exit, otherwise WP_Error. + */ + public function restore_temp_backup( array $temp_backups = array() ) { + global $wp_filesystem; + + $errors = new WP_Error(); + + if ( empty( $temp_backups ) ) { + $temp_backups = $this->temp_restores; + } + + foreach ( $temp_backups as $args ) { + if ( empty( $args['slug'] ) || empty( $args['src'] ) || empty( $args['dir'] ) ) { + return false; + } + + if ( ! $wp_filesystem->wp_content_dir() ) { + $errors->add( 'fs_no_content_dir', $this->strings['fs_no_content_dir'] ); + return $errors; + } + + $src = $wp_filesystem->wp_content_dir() . 'upgrade-temp-backup/' . $args['dir'] . '/' . $args['slug']; + $dest_dir = $wp_filesystem->find_folder( $args['src'] ); + $dest = trailingslashit( $dest_dir ) . $args['slug']; + + if ( $wp_filesystem->is_dir( $src ) ) { + // Cleanup. + if ( $wp_filesystem->is_dir( $dest ) && ! $wp_filesystem->delete( $dest, true ) ) { + $errors->add( + 'fs_temp_backup_delete', + sprintf( $this->strings['temp_backup_restore_failed'], $args['slug'] ) + ); + continue; + } + + // Move it. + $result = move_dir( $src, $dest, true ); + if ( is_wp_error( $result ) ) { + $errors->add( + 'fs_temp_backup_delete', + sprintf( $this->strings['temp_backup_restore_failed'], $args['slug'] ) + ); + continue; + } + } + } + + return $errors->has_errors() ? $errors : true; + } + + /** + * Deletes a temporary backup. + * + * @since 6.3.0 + * @since 6.6.0 Added the `$temp_backups` parameter. + * + * @global WP_Filesystem_Base $wp_filesystem WordPress filesystem subclass. + * + * @param array[] $temp_backups { + * Optional. An array of temporary backups. + * + * @type array ...$0 { + * Information about the backup. + * + * @type string $dir The temporary backup location in the upgrade-temp-backup directory. + * @type string $slug The item's slug. + * @type string $src The directory where the original is stored. For example, `WP_PLUGIN_DIR`. + * } + * } + * @return bool|WP_Error True on success, false on early exit, otherwise WP_Error. + */ + public function delete_temp_backup( array $temp_backups = array() ) { + global $wp_filesystem; + + $errors = new WP_Error(); + + if ( empty( $temp_backups ) ) { + $temp_backups = $this->temp_backups; + } + + foreach ( $temp_backups as $args ) { + if ( empty( $args['slug'] ) || empty( $args['dir'] ) ) { + return false; + } + + if ( ! $wp_filesystem->wp_content_dir() ) { + $errors->add( 'fs_no_content_dir', $this->strings['fs_no_content_dir'] ); + return $errors; + } + + $temp_backup_dir = $wp_filesystem->wp_content_dir() . "upgrade-temp-backup/{$args['dir']}/{$args['slug']}"; + + if ( ! $wp_filesystem->delete( $temp_backup_dir, true ) ) { + $errors->add( + 'temp_backup_delete_failed', + sprintf( $this->strings['temp_backup_delete_failed'], $args['slug'] ) + ); + continue; + } + } + + return $errors->has_errors() ? $errors : true; + } } /** Plugin_Upgrader class */