diff --git a/src/wp-admin/css/list-tables.css b/src/wp-admin/css/list-tables.css index 893326ba38ad6..0e8891ec0e0f3 100644 --- a/src/wp-admin/css/list-tables.css +++ b/src/wp-admin/css/list-tables.css @@ -1301,6 +1301,31 @@ ul.cat-checklist { text-decoration: underline; } +.plugins tr.paused th.check-column { + border-left: 4px solid #d54e21; +} + +.plugins tr.paused th, +.plugins tr.paused td { + background-color: #fef7f1; +} + +.plugins tr.paused .plugin-title, +.plugins .paused .dashicons-warning { + color: #dc3232; +} + +.plugins .paused .error-display p, +.plugins .paused .error-display code { + font-size: 90%; + font-style: italic; + color: rgb( 0, 0, 0, 0.7 ); +} + +.plugins .resume-link { + color: #dc3232; +} + .plugin-card .update-now:before { color: #f56e28; content: "\f463"; diff --git a/src/wp-admin/includes/admin-filters.php b/src/wp-admin/includes/admin-filters.php index 0816b2420b370..bc2f769b644ee 100644 --- a/src/wp-admin/includes/admin-filters.php +++ b/src/wp-admin/includes/admin-filters.php @@ -121,6 +121,7 @@ add_action( 'load-themes.php', 'wp_theme_update_rows', 20 ); // After wp_update_themes() is called. add_action( 'admin_notices', 'update_nag', 3 ); +add_action( 'admin_notices', 'paused_plugins_notice', 5 ); add_action( 'admin_notices', 'maintenance_nag', 10 ); add_filter( 'update_footer', 'core_update_footer' ); diff --git a/src/wp-admin/includes/class-wp-plugins-list-table.php b/src/wp-admin/includes/class-wp-plugins-list-table.php index 6e130f94b45c1..a7766117691a8 100644 --- a/src/wp-admin/includes/class-wp-plugins-list-table.php +++ b/src/wp-admin/includes/class-wp-plugins-list-table.php @@ -40,7 +40,7 @@ public function __construct( $args = array() ) { ); $status = 'all'; - if ( isset( $_REQUEST['plugin_status'] ) && in_array( $_REQUEST['plugin_status'], array( 'active', 'inactive', 'recently_activated', 'upgrade', 'mustuse', 'dropins', 'search' ) ) ) { + if ( isset( $_REQUEST['plugin_status'] ) && in_array( $_REQUEST['plugin_status'], array( 'active', 'inactive', 'recently_activated', 'upgrade', 'mustuse', 'dropins', 'search', 'paused' ) ) ) { $status = $_REQUEST['plugin_status']; } @@ -99,6 +99,7 @@ public function prepare_items() { 'upgrade' => array(), 'mustuse' => array(), 'dropins' => array(), + 'paused' => array(), ); $screen = $this->screen; @@ -209,6 +210,9 @@ public function prepare_items() { if ( $show_network_active ) { // On the non-network screen, show network-active plugins if allowed $plugins['active'][ $plugin_file ] = $plugin_data; + if ( is_plugin_paused( $plugin_file ) ) { + $plugins['paused'][ $plugin_file ] = $plugin_data; + } } else { // On the non-network screen, filter out network-active plugins unset( $plugins['all'][ $plugin_file ] ); @@ -218,6 +222,9 @@ public function prepare_items() { // On the non-network screen, populate the active list with plugins that are individually activated // On the network-admin screen, populate the active list with plugins that are network activated $plugins['active'][ $plugin_file ] = $plugin_data; + if ( is_plugin_paused( $plugin_file ) ) { + $plugins['paused'][ $plugin_file ] = $plugin_data; + } } else { if ( isset( $recently_activated[ $plugin_file ] ) ) { // Populate the recently activated list with plugins that have been recently activated @@ -436,6 +443,9 @@ protected function get_views() { case 'dropins': $text = _n( 'Drop-ins (%s)', 'Drop-ins (%s)', $count ); break; + case 'paused': + $text = _n( 'Paused (%s)', 'Paused (%s)', $count ); + break; case 'upgrade': $text = _n( 'Update Available (%s)', 'Update Available (%s)', $count ); break; @@ -647,6 +657,10 @@ public function single_row( $item ) { /* translators: %s: plugin name */ $actions['deactivate'] = '' . __( 'Deactivate' ) . ''; } + if ( current_user_can( 'resume_plugin' ) && is_plugin_paused( $plugin_file ) ) { + /* translators: %s: plugin name */ + $actions['resume'] = '' . __( 'Resume execution' ) . ''; + } } else { if ( current_user_can( 'activate_plugin', $plugin_file ) ) { /* translators: %s: plugin name */ @@ -753,6 +767,11 @@ public function single_row( $item ) { $class .= ' update'; } + $paused = is_plugin_paused( $plugin_file ); + if ( $paused ) { + $class .= ' paused'; + } + $plugin_slug = isset( $plugin_data['slug'] ) ? $plugin_data['slug'] : sanitize_title( $plugin_name ); printf( '', @@ -831,12 +850,48 @@ public function single_row( $item ) { * @param array $plugin_data An array of plugin data. * @param string $status Status of the plugin. Defaults are 'All', 'Active', * 'Inactive', 'Recently Activated', 'Upgrade', 'Must-Use', - * 'Drop-ins', 'Search'. + * 'Drop-ins', 'Search', 'Paused' */ $plugin_meta = apply_filters( 'plugin_row_meta', $plugin_meta, $plugin_file, $plugin_data, $status ); echo implode( ' | ', $plugin_meta ); - echo ''; + echo ''; + + if ( $paused ) { + echo sprintf( + '

%s

', + __( 'This plugin failed to load properly and was paused within the admin backend.' ) + ); + + $error = wp_get_plugin_error( $plugin_file ); + + if ( false !== $error ) { + $constants = get_defined_constants( true ); + $constants = isset( $constants['Core'] ) ? $constants['Core'] : $constants['internal']; + + foreach ( $constants as $constant => $value ) { + if ( 0 === strpos( $constant, 'E_' ) ) { + $core_errors[ $value ] = $constant; + } + } + + $error['type'] = $core_errors[ $error['type'] ]; + + echo sprintf( + '

%s

', + sprintf( + __( 'The plugin caused an error of type %1$s in line %2$s of the file %3$s. Error message: %4$s' ), + "{$error['type']}", + "{$error['line']}", + "{$error['file']}", + "{$error['message']}" + ) + ); + } + } + + + echo ''; break; default: $classes = "$column_name column-$column_name $class"; @@ -869,7 +924,7 @@ public function single_row( $item ) { * @param array $plugin_data An array of plugin data. * @param string $status Status of the plugin. Defaults are 'All', 'Active', * 'Inactive', 'Recently Activated', 'Upgrade', 'Must-Use', - * 'Drop-ins', 'Search'. + * 'Drop-ins', 'Search', 'Paused'. */ do_action( 'after_plugin_row', $plugin_file, $plugin_data, $status ); @@ -885,7 +940,7 @@ public function single_row( $item ) { * @param array $plugin_data An array of plugin data. * @param string $status Status of the plugin. Defaults are 'All', 'Active', * 'Inactive', 'Recently Activated', 'Upgrade', 'Must-Use', - * 'Drop-ins', 'Search'. + * 'Drop-ins', 'Search', 'Paused' */ do_action( "after_plugin_row_{$plugin_file}", $plugin_file, $plugin_data, $status ); } diff --git a/src/wp-admin/includes/plugin.php b/src/wp-admin/includes/plugin.php index c898fc516936a..ba9a5d2c99170 100644 --- a/src/wp-admin/includes/plugin.php +++ b/src/wp-admin/includes/plugin.php @@ -444,6 +444,8 @@ function _get_dropins() { 'install.php' => array( __( 'Custom installation script.' ), true ), // auto on installation 'maintenance.php' => array( __( 'Custom maintenance message.' ), true ), // auto on maintenance 'object-cache.php' => array( __( 'External object cache.' ), true ), // auto on load + 'php-error.php' => array( __( 'Custom PHP error message.' ), true ), // auto on error + 'shutdown-handler' => array( __( 'Custom PHP shutdown handler.' ), true ), // auto on error ); if ( is_multisite() ) { @@ -496,6 +498,57 @@ function is_plugin_inactive( $plugin ) { return ! is_plugin_active( $plugin ); } +/** + * Determines whether a plugin is technically active but was paused while + * loading. + * + * For more information on this and similar theme functions, check out + * the {@link https://developer.wordpress.org/themes/basics/conditional-tags/ + * Conditional Tags} article in the Theme Developer Handbook. + * + * @since 5.0.0 + * + * @param string $plugin Path to the plugin file relative to the plugins directory. + * @return bool True, if in the active plugins list. False, not in the list. + */ +function is_plugin_paused( $plugin ) { + if ( ! isset( $GLOBALS['_paused_plugins'] ) ) { + return false; + } + + if ( ! is_plugin_active( $plugin ) || is_plugin_active_for_network( $plugin ) ) { + return false; + } + + list( $plugin ) = explode( '/', $plugin ); + + return array_key_exists( $plugin, $GLOBALS['_paused_plugins'] ); +} + +/** + * Gets the error that was recorded for a paused plugin. + * + * @since 5.0.0 + * + * @param string $plugin Path to the plugin file relative to the plugins + * directory. + * @return array|false Array of error information as it was returned by + * `error_get_last()`, or false if none was recorded. + */ +function wp_get_plugin_error( $plugin ) { + if ( ! isset( $GLOBALS['_paused_plugins'] ) ) { + return false; + } + + list( $plugin ) = explode( '/', $plugin ); + + if ( ! array_key_exists( $plugin, $GLOBALS['_paused_plugins'] ) ) { + return false; + } + + return $GLOBALS['_paused_plugins'][ $plugin ]; +} + /** * Determines whether the plugin is active for the entire network. * @@ -693,6 +746,11 @@ function deactivate_plugins( $plugins, $silent = false, $network_wide = null ) { continue; } + // Clean up the database before deactivating the plugin. + if ( is_plugin_paused( $plugin ) ) { + resume_plugin( $plugin ); + } + $network_deactivating = false !== $network_wide && is_plugin_active_for_network( $plugin ); if ( ! $silent ) { @@ -887,6 +945,11 @@ function delete_plugins( $plugins, $deprecated = '' ) { uninstall_plugin( $plugin_file ); } + // Clean up the database before removing the plugin. + if ( is_plugin_paused( $plugin_file ) ) { + resume_plugin( $plugin_file ); + } + /** * Fires immediately before a plugin deletion attempt. * @@ -959,6 +1022,28 @@ function delete_plugins( $plugins, $deprecated = '' ) { return true; } +/** + * Resumes a single plugin. + * + * Resuming the plugin basically means removing its entry from the + * `pause_on_admin` database option. + * + * @since 5.0.0 + * + * @param string $plugin Single plugin to resume. + * + * @return bool|WP_Error True on success, false if `$plugin` was not paused, `WP_Error` on failure. + */ +function resume_plugin( $plugin ) { + $result = wp_forget_extension_error( 'plugins', $plugin ); + + if ( ! $result ) { + return new WP_Error( 'could_not_resume_plugin', __( 'Could not resume execution of the plugin.' ) ); + } + + return true; +} + /** * Validate active plugins * @@ -2066,3 +2151,35 @@ function wp_add_privacy_policy_content( $plugin_name, $policy_text ) { WP_Privacy_Policy_Content::add( $plugin_name, $policy_text ); } + +/** + * Renders an admin notice in case some plugins have been paused due to errors. + * + * @since 5.0.0 + * + * @return void + */ +function paused_plugins_notice() { + if ( 'plugins.php' === $GLOBALS['pagenow'] ) { + return; + } + + if ( ! current_user_can( 'deactivate_plugins' ) ) { + return; + } + + if ( ! isset( $GLOBALS['_paused_plugins'] ) || empty( $GLOBALS['_paused_plugins'] ) ) { + return; + } + + echo sprintf( + '

%s
%s

%s

', + __( 'One or more plugins failed to load properly.' ), + __( 'You can find more details and make changes on the Plugins screen.' ), + sprintf( + '%s', + admin_url( 'plugins.php?plugin_status=paused' ), + 'Go to the Plugins screen' + ) + ); +} diff --git a/src/wp-admin/plugins.php b/src/wp-admin/plugins.php index b482f474c0950..b21254adc9df7 100644 --- a/src/wp-admin/plugins.php +++ b/src/wp-admin/plugins.php @@ -389,6 +389,27 @@ } break; + case 'resume': + if ( ! current_user_can( 'resume_plugin', $plugin ) ) { + wp_die( __( 'Sorry, you are not allowed to resume execution of this plugin.' ) ); + } + + if ( is_multisite() && ! is_network_admin() && is_network_only_plugin( $plugin ) ) { + wp_redirect( self_admin_url( "plugins.php?plugin_status=$status&paged=$page&s=$s" ) ); + exit; + } + + check_admin_referer( 'resume-plugin_' . $plugin ); + + $result = resume_plugin( $plugin ); + + if ( is_wp_error( $result ) ) { + wp_die( $result ); + } + + wp_redirect( self_admin_url( "plugins.php?resume=true&plugin_status=$status&paged=$page&s=$s" ) ); + exit; + default: if ( isset( $_POST['checked'] ) ) { check_admin_referer( 'bulk-plugins' ); @@ -532,6 +553,8 @@

deactivated.' ); ?>

+ +

resumed.' ); ?>

diff --git a/src/wp-includes/capabilities.php b/src/wp-includes/capabilities.php index 375648e768f9d..e65ff0b5a2a7e 100644 --- a/src/wp-includes/capabilities.php +++ b/src/wp-includes/capabilities.php @@ -455,6 +455,7 @@ function map_meta_cap( $cap, $user_id ) { case 'deactivate_plugins': case 'activate_plugin': case 'deactivate_plugin': + case 'resume_plugin': $caps[] = 'activate_plugins'; if ( is_multisite() ) { // update_, install_, and delete_ are handled above with is_super_admin(). diff --git a/src/wp-includes/load.php b/src/wp-includes/load.php index 81014fdde848e..cef92695b1704 100644 --- a/src/wp-includes/load.php +++ b/src/wp-includes/load.php @@ -687,6 +687,36 @@ function wp_get_active_and_valid_plugins() { $plugins[] = WP_PLUGIN_DIR . '/' . $plugin; } } + + /* + * Remove plugins from the list of active plugins when we're on an admin or + * login screen and the plugin appears in the `pause_on_admin` list. + */ + if ( 'wp-login.php' === $GLOBALS['pagenow'] + || ( is_admin() && ! wp_doing_ajax() ) ) { + $pause_on_admin = (array) get_option( 'pause_on_admin', array() ); + + if ( ! array_key_exists( 'plugins', $pause_on_admin ) ) { + return $plugins; + } + + foreach ( $plugins as $index => $plugin ) { + $parts = explode( + '/', + str_replace( wp_normalize_path( WP_CONTENT_DIR . '/' ), '', wp_normalize_path( $plugin ) ) + ); + + $type = array_shift( $parts ); + $plugin = array_shift( $parts ); + + if ( array_key_exists( $plugin, $pause_on_admin[ $type ] ) ) { + unset( $plugins[ $index ] ); + // Store list of paused plugins for displaying an admin notice. + $GLOBALS['_paused_plugins'][ $plugin ] = $pause_on_admin[ $type ][ $plugin ]; + } + } + } + return $plugins; } @@ -1250,3 +1280,249 @@ function wp_finalize_scraping_edited_file_errors( $scrape_key ) { } echo "\n###### wp_scraping_result_end:$scrape_key ######\n"; } + +/** + * Prunes the array of recorded extension errors. + * + * @since 5.0.0 + * + * @param array $errors Array of errors to prune. + * @return array Pruned array of errors. + */ +function wp_prune_extension_errors( $errors ) { + foreach( array( 'plugins', 'mu-plugins', 'themes' ) as $type ) { + if ( ! array_key_exists( $type, $errors ) ) { + continue; + } + + switch( $type ) { + case 'plugins': + $active_plugins = array_merge( + (array) get_option( 'active_plugins', array() ), + (array) get_option( 'active_sitewide_plugins', array() ) + ); + + foreach( $errors[ $type ] as $plugin => $error ) { + $found = false; + + foreach ( $active_plugins as $active_plugin ) { + list( $active_plugin ) = explode( '/', $active_plugin ); + + if ( $active_plugin === $plugin ) { + $found = true; + break; + } + } + + if ( ! $found ) { + unset( $errors[ $type ][ $plugin ] ); + } + } + + break; + case 'mu-plugins': + // TODO: Implement MU-plugin-specific behavior. + break; + case 'themes': + // TODO: Implement theme-specific behavior. + break; + } + + if ( 0 === count( $errors[ $type ] ) ) { + unset( $errors[ $type ] ); + } + } + + return $errors; +} + +/** + * Records the extension error as a database option. + * + * @since 5.0.0 + * + * @global array $wp_theme_directories + * + * @param array $error Error that was triggered. + * @return bool Whether the error was correctly recorded. + */ +function wp_record_extension_error( $error ) { + global $wp_theme_directories; + + $path = ''; + + $error_file = wp_normalize_path( $error['file'] ); + $wp_plugin_dir = wp_normalize_path( WP_PLUGIN_DIR ); + $wpmu_plugin_dir = wp_normalize_path( WPMU_PLUGIN_DIR ); + + if ( 0 === strpos( $error_file, $wp_plugin_dir ) ) { + $type = 'plugins'; + $path = str_replace( $wp_plugin_dir . '/', '', $error_file ); + } elseif ( 0 === strpos( $error_file, $wpmu_plugin_dir ) ) { + $type = 'mu-plugins'; + $path = str_replace( $wpmu_plugin_dir . '/', '', $error_file ); + } else { + foreach ( $wp_theme_directories as $theme_directory ) { + $theme_directory = wp_normalize_path( $theme_directory ); + if ( 0 === strpos( $error_file, $theme_directory ) ) { + $type = 'themes'; + $path = str_replace( $theme_directory . '/', '', $error_file ); + } + } + } + + if ( empty( $type ) || empty( $path ) ) { + return false; + } + + $parts = explode( '/', $path ); + $extension = array_shift( $parts ); + + $errors = (array) get_option( 'pause_on_admin', array() ); + + $modified_errors = $errors; + + if ( ! array_key_exists( $type, $modified_errors ) ) { + $modified_errors[ $type ] = array(); + } + + $modified_errors[ $type ][ $extension ] = $error; + + $modified_errors = wp_prune_extension_errors( $modified_errors ); + + if ( $modified_errors === $errors ) { + return true; + } + + return update_option( 'pause_on_admin', $modified_errors ); +} + +/** + * Forgets a previously recorded extension error again. + * + * @since 5.0.0 + * + * @param string $type Type of the extension. + * @param string $extension Relative path of the extension. + * @return bool Whether the extension error was successfully forgotten. + */ +function wp_forget_extension_error( $type, $extension ) { + $errors = (array) get_option( 'pause_on_admin', array() ); + + if ( ! array_key_exists( $type, $errors ) ) { + return false; + } + + $modified_errors = $errors; + + switch ( $type ) { + case 'plugins': + list( $extension ) = explode( '/', $extension ); + } + + if ( array_key_exists( $extension, $modified_errors[ $type ] ) ) { + unset( $modified_errors[ $type ][ $extension ] ); + } + + $modified_errors = wp_prune_extension_errors( $modified_errors ); + + if ( $modified_errors === $errors ) { + return true; + } + + return update_option( 'pause_on_admin', $modified_errors ); +} + +/** + * Wraps the shutdown handler function so it can be made pluggable at a later + * stage. + * + * @since 5.0.0 + * + * @return void + */ +function wp_shutdown_handler_wrapper() { + if ( defined( 'WP_EXECUTION_SUCCEEDED' ) && WP_EXECUTION_SUCCEEDED ) { + return; + } + + // Load the pluggable shutdown handler in case we found one. + if ( function_exists( 'wp_handle_shutdown' ) ) { + $stop_propagation = (bool) wp_handle_shutdown(); + + if ( $stop_propagation ) { + return; + } + } + + $error = error_get_last(); + + // No error, just skip the error handling code. + if ( null === $error ) { + return; + } + + /* + * If the option API has not been loaded yet, we cannot persist our + * discovery, so there's no point in moving forward. + */ + if ( ! function_exists( 'get_option' ) ) { + return; + } + + // For now, we only trigger our safe mode on parse errors. + if ( ! isset( $error['type'] ) || E_PARSE !== $error['type'] ) { + return; + } + + try { + wp_record_extension_error( $error ); + + // Load custom PHP error template, if present. + if ( is_readable( WP_CONTENT_DIR . '/php-error.php' ) ) { + include WP_CONTENT_DIR . '/php-error.php'; + die(); + } + + $message = sprintf( + '

%s

', + __( 'The site is experiencing technical difficulties.' ) + ); + + if ( function_exists( 'get_admin_url' ) ) { + $url = get_admin_url(); + $message .= sprintf( + '

%s %s

', + __( 'Are you the site owner?' ), + $url, + __( 'Log into the admin backend to fix this.' ) + ); + } + + if ( function_exists( 'apply_filters' ) ) { + /** + * Filters the message that the default PHP error page displays. + * + * @since 5.0.0 + * + * @param string $message HTML error message to display. + */ + $message = apply_filters( 'wp_technical_issues_display', $message ); + } + + wp_die( $message ); + } catch ( Exception $exception ) { + // Catch exceptions and remain silent. + } +} + +/** + * Registers the WordPress premature shutdown handler. + * + * @since 5.0.0 + * + * @return void + */ +function wp_register_premature_shutdown_handler() { + register_shutdown_function( 'wp_shutdown_handler_wrapper' ); +} diff --git a/src/wp-settings.php b/src/wp-settings.php index eb632238f031c..c633f05b1d5f9 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -20,6 +20,9 @@ require( ABSPATH . WPINC . '/default-constants.php' ); require_once( ABSPATH . WPINC . '/plugin.php' ); +// Make sure we register the premature shutdown handler as soon as possible. +wp_register_premature_shutdown_handler(); + /* * These can't be directly globalized in version.php. When updating, * we're including version.php from another installation and don't want @@ -40,6 +43,16 @@ // Set initial default constants including WP_MEMORY_LIMIT, WP_MAX_MEMORY_LIMIT, WP_DEBUG, SCRIPT_DEBUG, WP_CONTENT_DIR and WP_CACHE. wp_initial_constants(); +/* + * Allow an optional shutdown handler to be included through a pluggable file. + * This file should register a function `wp_handle_shutdown( $context )` that + * returns a boolean value. If the return value evaluates to false, the default + * shutdown handler will not be executed. + */ +if ( is_readable( WP_CONTENT_DIR . '/shutdown-handler.php' ) ) { + include WP_CONTENT_DIR . '/shutdown-handler.php'; +} + // Check for the required PHP version and for the MySQL extension or a database drop-in. wp_check_php_mysql_versions(); @@ -482,3 +495,12 @@ * @since 3.0.0 */ do_action( 'wp_loaded' ); + +/* + * Store the fact that we could successfully execute the entire WordPress + * lifecycle. This is used to skip the premature shutdown handler, as it cannot + * be unregistered. + */ +if ( ! defined( 'WP_EXECUTION_SUCCEEDED' ) ) { + define( 'WP_EXECUTION_SUCCEEDED', true ); +}