diff --git a/src/wp-admin/css/list-tables.css b/src/wp-admin/css/list-tables.css index 992f69c2f8..6dcac9d56f 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 a1efcc62f4..a25eda7f42 100644 --- a/src/wp-admin/includes/admin-filters.php +++ b/src/wp-admin/includes/admin-filters.php @@ -123,6 +123,8 @@ add_action( 'load-plugins.php', 'wp_plugin_update_rows', 20 ); // After wp_updat 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', 'paused_themes_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 1f540c9ed2..a9063223f9 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 @@ class WP_Plugins_List_Table extends WP_List_Table { ); $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 @@ class WP_Plugins_List_Table extends WP_List_Table { 'upgrade' => array(), 'mustuse' => array(), 'dropins' => array(), + 'paused' => array(), ); $screen = $this->screen; @@ -209,6 +210,9 @@ class WP_Plugins_List_Table extends WP_List_Table { 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 @@ class WP_Plugins_List_Table extends WP_List_Table { // 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 @@ -438,6 +445,10 @@ class WP_Plugins_List_Table extends WP_List_Table { case 'dropins': $text = _n( 'Drop-ins (%s)', 'Drop-ins (%s)', $count ); break; + case 'paused': + /* translators: %s: plugin count */ + $text = _n( 'Paused (%s)', 'Paused (%s)', $count ); + break; case 'upgrade': $text = _n( 'Update Available (%s)', 'Update Available (%s)', $count ); break; @@ -625,11 +636,19 @@ class WP_Plugins_List_Table extends WP_List_Table { /* translators: %s: plugin name */ $actions['deactivate'] = '' . __( 'Network Deactivate' ) . ''; } + if ( current_user_can( 'manage_network_plugins' ) && count_paused_plugin_sites_for_network( $plugin_file ) ) { + /* translators: %s: plugin name */ + $actions['resume'] = '' . __( 'Network Resume' ) . ''; + } } else { if ( current_user_can( 'manage_network_plugins' ) ) { /* translators: %s: plugin name */ $actions['activate'] = '' . __( 'Network Activate' ) . ''; } + if ( current_user_can( 'manage_network_plugins' ) && count_paused_plugin_sites_for_network( $plugin_file ) ) { + /* translators: %s: plugin name */ + $actions['resume'] = '' . __( 'Network Resume' ) . ''; + } if ( current_user_can( 'delete_plugins' ) && ! is_plugin_active( $plugin_file ) ) { /* translators: %s: plugin name */ $actions['delete'] = '' . __( 'Delete' ) . ''; @@ -640,6 +659,10 @@ class WP_Plugins_List_Table extends WP_List_Table { $actions = array( 'network_active' => __( 'Network Active' ), ); + if ( ! $restrict_network_only && current_user_can( 'resume_plugin' ) && is_plugin_paused( $plugin_file ) ) { + /* translators: %s: plugin name */ + $actions['resume'] = '' . __( 'Resume' ) . ''; + } } elseif ( $restrict_network_only ) { $actions = array( 'network_only' => __( 'Network Only' ), @@ -649,6 +672,10 @@ class WP_Plugins_List_Table extends WP_List_Table { /* 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' ) . ''; + } } else { if ( current_user_can( 'activate_plugin', $plugin_file ) ) { /* translators: %s: plugin name */ @@ -755,6 +782,12 @@ class WP_Plugins_List_Table extends WP_List_Table { $class .= ' update'; } + $paused = is_plugin_paused( $plugin_file ); + $paused_on_network_sites_count = $screen->in_admin( 'network' ) ? count_paused_plugin_sites_for_network( $plugin_file ) : 0; + if ( $paused || $paused_on_network_sites_count ) { + $class .= ' paused'; + } + $plugin_slug = isset( $plugin_data['slug'] ) ? $plugin_data['slug'] : sanitize_title( $plugin_name ); printf( '', @@ -833,12 +866,54 @@ class WP_Plugins_List_Table extends WP_List_Table { * @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 || $paused_on_network_sites_count ) { + $notice_text = __( 'This plugin failed to load properly and was paused within the admin backend.' ); + if ( $screen->in_admin( 'network' ) && $paused_on_network_sites_count ) { + $notice_text = sprintf( + /* translators: %s: number of sites */ + _n( 'This plugin failed to load properly and was paused within the admin backend for %s site.', 'This plugin failed to load properly and was paused within the admin backend for %s sites.', $paused_on_network_sites_count ), + number_format_i18n( $paused_on_network_sites_count ) + ); + } + + printf( '

%s

', $notice_text ); + + $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'] ]; + + printf( + '

%s

', + sprintf( + /* translators: 1: error type, 2: error line number, 3: error file name, 4: error message */ + __( '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"; @@ -871,7 +946,7 @@ class WP_Plugins_List_Table extends WP_List_Table { * @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 ); @@ -887,7 +962,7 @@ class WP_Plugins_List_Table extends WP_List_Table { * @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 c898fc5169..412c5330e4 100644 --- a/src/wp-admin/includes/plugin.php +++ b/src/wp-admin/includes/plugin.php @@ -438,12 +438,14 @@ function get_dropins() { */ function _get_dropins() { $dropins = array( - 'advanced-cache.php' => array( __( 'Advanced caching plugin.' ), 'WP_CACHE' ), // WP_CACHE - 'db.php' => array( __( 'Custom database class.' ), true ), // auto on load - 'db-error.php' => array( __( 'Custom database error message.' ), true ), // auto on error - '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 + 'advanced-cache.php' => array( __( 'Advanced caching plugin.' ), 'WP_CACHE' ), // WP_CACHE + 'db.php' => array( __( 'Custom database class.' ), true ), // auto on load + 'db-error.php' => array( __( 'Custom database error message.' ), true ), // auto on error + '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.php' => array( __( 'Custom PHP shutdown handler.' ), true ), // auto on error ); if ( is_multisite() ) { @@ -496,6 +498,84 @@ 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.1.0 + * + * @param string $plugin Path to the plugin file relative to the plugins directory. + * @return bool True, if in the list of paused plugins. 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.1.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 ]; +} + +/** + * Gets the number of sites on which a specific plugin is paused. + * + * @since 5.1.0 + * + * @param string $plugin Path to the plugin file relative to the plugins directory. + * @return int Site count. + */ +function count_paused_plugin_sites_for_network( $plugin ) { + if ( ! is_multisite() ) { + return is_plugin_paused( $plugin ) ? 1 : 0; + } + + list( $plugin ) = explode( '/', $plugin ); + + $query_args = array( + 'count' => true, + 'number' => 0, + 'network_id' => get_current_network_id(), + 'meta_query' => array( + wp_paused_plugins()->get_site_meta_query_clause( $plugin ), + ), + ); + + return get_sites( $query_args ); +} + /** * Determines whether the plugin is active for the entire network. * @@ -693,6 +773,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 +972,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 +1049,57 @@ function delete_plugins( $plugins, $deprecated = '' ) { return true; } +/** + * Tries to resume a single plugin. + * + * If a redirect was provided, we first ensure the plugin does not throw fatal + * errors anymore. + * + * The way it works is by setting the redirection to the error before trying to + * include the plugin file. If the plugin fails, then the redirection will not + * be overwritten with the success message and the plugin will not be resumed. + * + * @since 5.1.0 + * + * @param string $plugin Single plugin to resume. + * @param string $redirect Optional. URL to redirect to. Default empty string. + * @param bool $network_wide Optional. Whether to resume the plugin for the entire + * network. Default false. + * @return bool|WP_Error True on success, false if `$plugin` was not paused, + * `WP_Error` on failure. + */ +function resume_plugin( $plugin, $redirect = '', $network_wide = false ) { + /* + * We'll override this later if the plugin could be included without + * creating a fatal error. + */ + if ( ! empty( $redirect ) ) { + wp_redirect( + add_query_arg( + '_error_nonce', + wp_create_nonce( 'plugin-resume-error_' . $plugin ), + $redirect + ) + ); + + // Load the plugin to test whether it throws a fatal error. + ob_start(); + plugin_sandbox_scrape( $plugin ); + ob_clean(); + } + + $result = wp_forget_extension_error( 'plugins', $plugin, $network_wide ); + + if ( ! $result ) { + return new WP_Error( + 'could_not_resume_plugin', + __( 'Could not resume the plugin.' ) + ); + } + + return true; +} + /** * Validate active plugins * @@ -2066,3 +2207,33 @@ 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.1.0 + */ +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; + } + + printf( + '

%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/includes/theme.php b/src/wp-admin/includes/theme.php index f67886aaf1..1b3d2e2f06 100644 --- a/src/wp-admin/includes/theme.php +++ b/src/wp-admin/includes/theme.php @@ -763,3 +763,127 @@ function customize_themes_print_templates() { true, + 'number' => 0, + 'network_id' => get_current_network_id(), + 'meta_query' => array( + wp_paused_themes()->get_site_meta_query_clause( $theme ), + ), + ); + + return get_sites( $query_args ); +} + +/** + * Tries to resume a single theme. + * + * @since 5.1.0 + * + * @param string $theme Single theme to resume. + * @return bool|WP_Error True on success, false if `$theme` was not paused, + * `WP_Error` on failure. + */ +function resume_theme( $theme ) { + $result = wp_forget_extension_error( 'themes', $theme ); + + if ( ! $result ) { + return new WP_Error( + 'could_not_resume_theme', + __( 'Could not resume the theme.' ) + ); + } + + return true; +} + +/** + * Renders an admin notice in case some themes have been paused due to errors. + * + * @since 5.1.0 + */ +function paused_themes_notice() { + if ( 'themes.php' === $GLOBALS['pagenow'] ) { + return; + } + + if ( ! current_user_can( 'switch_themes' ) ) { + return; + } + + if ( ! isset( $GLOBALS['_paused_themes'] ) || empty( $GLOBALS['_paused_themes'] ) ) { + return; + } + + printf( + '

%s
%s

%s

', + __( 'One or more themes failed to load properly.' ), + __( 'You can find more details and make changes on the Themes screen.' ), + sprintf( + '%s', + admin_url( 'themes.php' ), + 'Go to the Themes screen' + ) + ); +} diff --git a/src/wp-admin/plugins.php b/src/wp-admin/plugins.php index c80e96831f..6c41e31b2f 100644 --- a/src/wp-admin/plugins.php +++ b/src/wp-admin/plugins.php @@ -389,6 +389,27 @@ if ( $action ) { } break; + case 'resume': + if ( ! current_user_can( 'resume_plugin', $plugin ) ) { + wp_die( __( 'Sorry, you are not allowed to resume 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, self_admin_url( 'plugins.php?error=resuming' ), is_network_admin() ); + + 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' ); @@ -488,6 +509,8 @@ if ( isset( $_GET['error'] ) ) : $_GET['charsout'] ); $errmsg .= ' ' . __( 'If you notice “headers already sent” messages, problems with syndication feeds or other issues, try deactivating or removing this plugin.' ); + } elseif ( 'resuming' === $_GET['error'] ) { + $errmsg = __( 'Plugin could not be resumed because it triggered a fatal error.' ); } else { $errmsg = __( 'Plugin could not be activated because it triggered a fatal error.' ); } @@ -541,6 +564,8 @@ elseif ( isset( $_GET['deleted'] ) ) :

deactivated.' ); ?>

+ +

resumed.' ); ?>

diff --git a/src/wp-admin/themes.php b/src/wp-admin/themes.php index 82d95ea15d..754b75808d 100644 --- a/src/wp-admin/themes.php +++ b/src/wp-admin/themes.php @@ -33,6 +33,26 @@ if ( current_user_can( 'switch_themes' ) && isset( $_GET['action'] ) ) { switch_theme( $theme->get_stylesheet() ); wp_redirect( admin_url( 'themes.php?activated=true' ) ); exit; + } elseif ( 'resume' === $_GET['action'] ) { + check_admin_referer( 'resume-theme_' . $_GET['stylesheet'] ); + $theme = wp_get_theme( $_GET['stylesheet'] ); + + if ( ! current_user_can( 'resume_themes' ) ) { + wp_die( + '

' . __( 'You need a higher level of permission.' ) . '

' . + '

' . __( 'Sorry, you are not allowed to resume this theme.' ) . '

', + 403 + ); + } + + $result = resume_theme( $theme->get_stylesheet() ); + + if ( is_wp_error( $result ) ) { + wp_die( $result ); + } + + wp_redirect( admin_url( 'themes.php?resumed=true' ) ); + exit; } elseif ( 'delete' == $_GET['action'] ) { check_admin_referer( 'delete-theme_' . $_GET['stylesheet'] ); $theme = wp_get_theme( $_GET['stylesheet'] ); @@ -173,25 +193,33 @@ require_once( ABSPATH . 'wp-admin/admin-header.php' );
-

+

- -

- -

- +

+ +

+

+

+

@@ -351,6 +380,9 @@ if ( ! is_multisite() && current_user_can( 'edit_themes' ) && $broken_themes = w + + + @@ -363,6 +395,27 @@ if ( ! is_multisite() && current_user_can( 'edit_themes' ) && $broken_themes = w get( 'Name' ) ? $broken_theme->display( 'Name' ) : $broken_theme->get_stylesheet(); ?> errors()->get_error_message(); ?> errors()->get_error_code() ) { + $stylesheet = $broken_theme->get_stylesheet(); + $resume_url = add_query_arg( + array( + 'action' => 'resume', + 'stylesheet' => urlencode( $stylesheet ), + ), + admin_url( 'themes.php' ) + ); + $resume_url = wp_nonce_url( $resume_url, 'resume-theme_' . $stylesheet ); + ?> + + + + get_stylesheet(); $delete_url = add_query_arg( diff --git a/src/wp-includes/capabilities.php b/src/wp-includes/capabilities.php index c192639608..afb6f59507 100644 --- a/src/wp-includes/capabilities.php +++ b/src/wp-includes/capabilities.php @@ -464,6 +464,14 @@ function map_meta_cap( $cap, $user_id ) { } } break; + case 'resume_plugin': + // Even in a multisite, regular administrators should be able to resume a plugin. + $caps[] = 'activate_plugins'; + break; + case 'resume_themes': + // Even in a multisite, regular administrators should be able to resume a theme. + $caps[] = 'switch_themes'; + break; case 'delete_user': case 'delete_users': // If multisite only super admins can delete users. diff --git a/src/wp-includes/class-wp-paused-extensions-storage.php b/src/wp-includes/class-wp-paused-extensions-storage.php new file mode 100644 index 0000000000..37b39d2788 --- /dev/null +++ b/src/wp-includes/class-wp-paused-extensions-storage.php @@ -0,0 +1,221 @@ +option_name = $option_name; + $this->meta_prefix = $meta_prefix; + } + + /** + * Records an extension error. + * + * Only one error is stored per extension, with subsequent errors for the same extension overriding the + * previously stored error. + * + * @since 5.1.0 + * + * @param string $extension Plugin or theme directory name. + * @param array $error { + * Error that was triggered. + * + * @type string $type The error type. + * @type string $file The name of the file in which the error occurred. + * @type string $line The line number in which the error occurred. + * @type string $message The error message. + * } + * @return bool True on success, false on failure. + */ + public function record( $extension, $error ) { + if ( ! $this->is_api_loaded() ) { + return false; + } + + if ( is_multisite() && is_site_meta_supported() ) { + // Do not update if the error is already stored. + if ( get_site_meta( get_current_blog_id(), $this->meta_prefix . $extension, true ) === $error ) { + return true; + } + + return (bool) update_site_meta( get_current_blog_id(), $this->meta_prefix . $extension, $error ); + } + + $paused_extensions = $this->get_all(); + + // Do not update if the error is already stored. + if ( isset( $paused_extensions[ $extension ] ) && $paused_extensions[ $extension ] === $error ) { + return true; + } + + $paused_extensions[ $extension ] = $error; + + return update_option( $this->option_name, $paused_extensions ); + } + + /** + * Forgets a previously recorded extension error. + * + * @since 5.1.0 + * + * @param string $extension Plugin or theme directory name. + * @return bool True on success, false on failure. + */ + public function forget( $extension ) { + if ( ! $this->is_api_loaded() ) { + return false; + } + + if ( is_multisite() && is_site_meta_supported() ) { + // Do not delete if no error is stored. + if ( get_site_meta( get_current_blog_id(), $this->meta_prefix . $extension ) === array() ) { + return true; + } + + return (bool) delete_site_meta( get_current_blog_id(), $this->meta_prefix . $extension ); + } + + $paused_extensions = $this->get_all(); + + // Do not delete if no error is stored. + if ( ! isset( $paused_extensions[ $extension ] ) ) { + return true; + } + + // Clean up the entire option if we're removing the only error. + if ( count( $paused_extensions ) === 1 ) { + return delete_option( $this->option_name ); + } + + unset( $paused_extensions[ $extension ] ); + + return update_option( $this->option_name, $paused_extensions ); + } + + /** + * Gets the error for an extension, if paused. + * + * @since 5.1.0 + * + * @param string $extension Plugin or theme directory name. + * @return array|null Error that is stored, or null if the extension is not paused. + */ + public function get( $extension ) { + if ( ! $this->is_api_loaded() ) { + return null; + } + + if ( is_multisite() && is_site_meta_supported() ) { + $error = get_site_meta( get_current_blog_id(), $this->meta_prefix . $extension, true ); + if ( ! $error ) { + return null; + } + + return $error; + } + + $paused_extensions = $this->get_all(); + + if ( ! isset( $paused_extensions[ $extension ] ) ) { + return null; + } + + return $paused_extensions[ $extension ]; + } + + /** + * Gets the paused extensions with their errors. + * + * @since 5.1.0 + * + * @return array Associative array of $extension => $error pairs. + */ + public function get_all() { + if ( ! $this->is_api_loaded() ) { + return array(); + } + + if ( is_multisite() && is_site_meta_supported() ) { + $site_metadata = get_site_meta( get_current_blog_id() ); + + $paused_extensions = array(); + foreach ( $site_metadata as $meta_key => $meta_values ) { + if ( 0 !== strpos( $meta_key, $this->meta_prefix ) ) { + continue; + } + + $error = maybe_unserialize( array_shift( $meta_values ) ); + + $paused_extensions[ substr( $meta_key, strlen( $this->meta_prefix ) ) ] = $error; + } + + return $paused_extensions; + } + + return (array) get_option( $this->option_name, array() ); + } + + /** + * Gets the site meta query clause for querying sites with paused extensions. + * + * @since 5.1.0 + * + * @param string $extension Plugin or theme directory name. + * @return array A single clause to add to a meta query. + */ + public function get_site_meta_query_clause( $extension ) { + return array( + 'key' => $this->meta_prefix . $extension, + 'compare_key' => '=', + ); + } + + /** + * Checks whether the underlying API to store paused extensions is loaded. + * + * @since 5.1.0 + * + * @return bool True if the API is loaded, false otherwise. + */ + protected function is_api_loaded() { + if ( is_multisite() ) { + return function_exists( 'is_site_meta_supported' ) && function_exists( 'get_site_meta' ); + } + + return function_exists( 'get_option' ); + } +} diff --git a/src/wp-includes/class-wp-shutdown-handler.php b/src/wp-includes/class-wp-shutdown-handler.php new file mode 100644 index 0000000000..e22038f870 --- /dev/null +++ b/src/wp-includes/class-wp-shutdown-handler.php @@ -0,0 +1,176 @@ +detect_error() ) { + return; + } + + // Redirect the request to catch multiple errors in one go. + $this->redirect_protected(); + + // Display the PHP error template. + $this->display_error_template(); + } catch ( Exception $e ) { + // Catch exceptions and remain silent. + } + } + + /** + * Detects the error causing the crash and stores it if one was found. + * + * @since 5.1.0 + * + * @return bool True if an error was found and stored, false otherwise. + */ + protected function detect_error() { + $error = error_get_last(); + + // No error, just skip the error handling code. + if ( null === $error ) { + return false; + } + + // Bail if this error should not be handled. + if ( ! wp_should_handle_error( $error ) ) { + return false; + } + + // Try to store the error so that the respective extension is paused. + return wp_record_extension_error( $error ); + } + + /** + * Redirects the current request to allow recovering multiple errors in one go. + * + * The redirection will only happen when on a protected endpoint. + * + * It must be ensured that this method is only called when an error actually occurred and will not occur on the + * next request again. Otherwise it will create a redirect loop. + * + * @since 5.1.0 + */ + protected function redirect_protected() { + // Do not redirect requests on non-protected endpoints. + if ( ! is_protected_endpoint() ) { + return; + } + + // Pluggable is usually loaded after plugins, so we manually include it here for redirection functionality. + if ( ! function_exists( 'wp_redirect' ) ) { + include ABSPATH . WPINC . '/pluggable.php'; + } + + $scheme = is_ssl() ? 'https://' : 'http://'; + + $url = "{$scheme}{$_SERVER['HTTP_HOST']}{$_SERVER['REQUEST_URI']}"; + wp_redirect( $url ); + exit; + } + + /** + * Displays the PHP error template and sends the HTTP status code, typically 500. + * + * A drop-in 'php-error.php' can be used as a custom template. This drop-in should control the HTTP status code and + * print the HTML markup indicating that a PHP error occurred. Alternatively, {@see wp_die()} can be used. Note + * that this drop-in may potentially be executed very early in the WordPress bootstrap process, so any core + * functions used that are not part of `wp-includes/load.php` should be checked for before being called. + * + * The default template also displays a link to the admin in order to fix the problem, however doing so is not + * mandatory. + * + * @since 5.1.0 + */ + protected function display_error_template() { + if ( defined( 'WP_CONTENT_DIR' ) ) { + // Load custom PHP error template, if present. + $php_error_pluggable = WP_CONTENT_DIR . '/php-error.php'; + if ( is_readable( $php_error_pluggable ) ) { + require_once $php_error_pluggable; + die(); + } + } + + // Otherwise, fail with a `wp_die()` message. + $message = $this->get_error_message_markup(); + + // `wp_die()` wraps the message in paragraph tags, so let's just try working around that. + if ( substr( $message, 0, 3 ) === '

' && substr( $message, -4 ) === '

' ) { + $message = substr( $message, 3, -4 ); + } + + wp_die( $message, '', 500 ); + } + + /** + * Returns the error message markup to display in the default error template. + * + * @since 5.1.0 + * + * @return string Error message HTML output. + */ + protected function get_error_message_markup() { + if ( ! function_exists( '__' ) ) { + function __( $text ) { + return $text; + } + } + + $message = sprintf( + '

%s

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

%s %s

', + __( 'Are you the site owner?' ), + admin_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.1.0 + * + * @param string $message HTML error message to display. + */ + $message = apply_filters( 'wp_technical_issues_display', $message ); + } + + return $message; + } +} diff --git a/src/wp-includes/class-wp-theme.php b/src/wp-includes/class-wp-theme.php index a9ddb2251a..8b9c2c8815 100644 --- a/src/wp-includes/class-wp-theme.php +++ b/src/wp-includes/class-wp-theme.php @@ -371,6 +371,10 @@ final class WP_Theme implements ArrayAccess { $this->parent = new WP_Theme( $this->template, isset( $theme_root_template ) ? $theme_root_template : $this->theme_root, $this ); } + if ( wp_paused_themes()->get( $this->stylesheet ) && ( ! is_wp_error( $this->errors ) || ! isset( $this->errors->errors['theme_paused'] ) ) ) { + $this->errors = new WP_Error( 'theme_paused', __( 'This theme failed to load properly and was paused within the admin backend.' ) ); + } + // We're good. If we didn't retrieve from cache, set it. if ( ! is_array( $cache ) ) { $cache = array( diff --git a/src/wp-includes/error-protection.php b/src/wp-includes/error-protection.php new file mode 100644 index 0000000000..f12bd2409f --- /dev/null +++ b/src/wp-includes/error-protection.php @@ -0,0 +1,166 @@ +record( $extension, $error ); +} + +/** + * Forgets a previously recorded extension error again. + * + * @since 5.1.0 + * + * @param string $type Type of the extension. + * @param string $extension Relative path of the extension. + * @param bool $network_wide Optional. Whether to resume the plugin for the entire + * network. Default false. + * @return bool Whether the extension error was successfully forgotten. + */ +function wp_forget_extension_error( $type, $extension, $network_wide = false ) { + switch ( $type ) { + case 'plugins': + $callback = 'wp_paused_plugins'; + list( $extension ) = explode( '/', $extension ); + break; + case 'themes': + $callback = 'wp_paused_themes'; + list( $extension ) = explode( '/', $extension ); + break; + } + + if ( empty( $callback ) || empty( $extension ) ) { + return false; + } + + // Handle manually since the regular APIs do not expose this functionality. + if ( $network_wide && is_site_meta_supported() ) { + $site_meta_query_clause = call_user_func( $callback )->get_site_meta_query_clause( $extension ); + return delete_metadata( 'blog', 0, $site_meta_query_clause['key'], '', true ); + } + + return call_user_func( $callback )->forget( $extension ); +} + +/** + * Determines whether we are dealing with an error that WordPress should handle + * in order to protect the admin backend against WSODs. + * + * @param array $error Error information retrieved from error_get_last(). + * + * @return bool Whether WordPress should handle this error. + */ +function wp_should_handle_error( $error ) { + if ( ! isset( $error['type'] ) ) { + return false; + } + + $error_types_to_handle = array( + E_ERROR, + E_PARSE, + E_USER_ERROR, + E_COMPILE_ERROR, + E_RECOVERABLE_ERROR, + ); + + return in_array( $error['type'], $error_types_to_handle, true ); +} + +/** + * Registers the WordPress premature shutdown handler. + * + * @since 5.1.0 + */ +function wp_register_premature_shutdown_handler() { + $handler = null; + if ( defined( 'WP_CONTENT_DIR' ) && is_readable( WP_CONTENT_DIR . '/shutdown-handler.php' ) ) { + $handler = include WP_CONTENT_DIR . '/shutdown-handler.php'; + } + + if ( ! is_object( $handler ) || ! is_callable( array( $handler, 'handle' ) ) ) { + $handler = new WP_Shutdown_Handler(); + } + + register_shutdown_function( array( $handler, 'handle' ) ); +} diff --git a/src/wp-includes/load.php b/src/wp-includes/load.php index e959d79db8..e404a68f9f 100644 --- a/src/wp-includes/load.php +++ b/src/wp-includes/load.php @@ -696,9 +696,117 @@ 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 endpoint + * that should be protected against WSODs and the plugin is paused. + */ + if ( is_protected_endpoint() ) { + $plugins = wp_skip_paused_plugins( $plugins ); + } + return $plugins; } +/** + * Filters a given list of plugins, removing any paused plugins from it. + * + * @since 5.1.0 + * + * @param array $plugins List of absolute plugin main file paths. + * @return array Filtered value of $plugins, without any paused plugins. + */ +function wp_skip_paused_plugins( array $plugins ) { + $paused_plugins = wp_paused_plugins()->get_all(); + + if ( empty( $paused_plugins ) ) { + return $plugins; + } + + foreach ( $plugins as $index => $plugin ) { + list( $plugin ) = explode( '/', plugin_basename( $plugin ) ); + + if ( array_key_exists( $plugin, $paused_plugins ) ) { + unset( $plugins[ $index ] ); + + // Store list of paused plugins for displaying an admin notice. + $GLOBALS['_paused_plugins'][ $plugin ] = $paused_plugins[ $plugin ]; + } + } + + return $plugins; +} + +/** + * Retrieves an array of active and valid themes. + * + * While upgrading or installing WordPress, no themes are returned. + * + * @since 5.1.0 + * @access private + * + * @return array Array of paths to theme directories. + */ +function wp_get_active_and_valid_themes() { + global $pagenow; + + $themes = array(); + + if ( wp_installing() && 'wp-activate.php' !== $pagenow ) { + return $themes; + } + + if ( TEMPLATEPATH !== STYLESHEETPATH ) { + $themes[] = STYLESHEETPATH; + } + + $themes[] = TEMPLATEPATH; + + /* + * Remove themes from the list of active themes when we're on an endpoint + * that should be protected against WSODs and the theme is paused. + */ + if ( is_protected_endpoint() ) { + $themes = wp_skip_paused_themes( $themes ); + + // If no active and valid themes exist, skip loading themes. + if ( empty( $themes ) ) { + add_filter( 'wp_using_themes', '__return_false' ); + } + } + + return $themes; +} + +/** + * Filters a given list of themes, removing any paused themes from it. + * + * @since 5.1.0 + * + * @param array $themes List of absolute theme directory paths. + * @return array Filtered value of $themes, without any paused themes. + */ +function wp_skip_paused_themes( array $themes ) { + $paused_themes = wp_paused_themes()->get_all(); + + if ( empty( $paused_themes ) ) { + return $themes; + } + + foreach ( $themes as $index => $theme ) { + $theme = basename( $theme ); + + if ( array_key_exists( $theme, $paused_themes ) ) { + unset( $themes[ $index ] ); + + // Store list of paused themes for displaying an admin notice. + $GLOBALS['_paused_themes'][ $theme ] = $paused_themes[ $theme ]; + } + } + + return $themes; +} + /** * Set internal encoding. * @@ -1163,6 +1271,106 @@ function wp_doing_ajax() { return apply_filters( 'wp_doing_ajax', defined( 'DOING_AJAX' ) && DOING_AJAX ); } +/** + * Determines whether the current request should use themes. + * + * @since 5.1.0 + * + * @return bool True if themes should be used, false otherwise. + */ +function wp_using_themes() { + /** + * Filters whether the current request should use themes. + * + * @since 5.1.0 + * + * @param bool $wp_using_themes Whether the current request should use themes. + */ + return apply_filters( 'wp_using_themes', defined( 'WP_USE_THEMES' ) && WP_USE_THEMES ); +} + +/** + * Determines whether we are currently on an endpoint that should be protected against WSODs. + * + * @since 5.1.0 + * + * @return bool True if the current endpoint should be protected. + */ +function is_protected_endpoint() { + // Protect login pages. + if ( isset( $GLOBALS['pagenow'] ) && 'wp-login.php' === $GLOBALS['pagenow'] ) { + return true; + } + + // Protect the admin backend. + if ( is_admin() && ! wp_doing_ajax() ) { + return true; + } + + // Protect AJAX actions that could help resolve a fatal error should be available. + if ( is_protected_ajax_action() ) { + return true; + } + + /** + * Filters whether the current request is against a protected endpoint. + * + * This filter is only fired when an endpoint is requested which is not already protected by + * WordPress core. As such, it exclusively allows providing further protected endpoints in + * addition to the admin backend, login pages and protected AJAX actions. + * + * @since 5.1.0 + * + * @param bool $is_protected_endpoint Whether the currently requested endpoint is protected. Default false. + */ + return (bool) apply_filters( 'is_protected_endpoint', false ); +} + +/** + * Determines whether we are currently handling an AJAX action that should be protected against WSODs. + * + * @since 5.1.0 + * + * @return bool True if the current AJAX action should be protected. + */ +function is_protected_ajax_action() { + if ( ! wp_doing_ajax() ) { + return false; + } + + if ( ! isset( $_REQUEST['action'] ) ) { + return false; + } + + $actions_to_protect = array( + 'edit-theme-plugin-file', // Saving changes in the core code editor. + 'heartbeat', // Keep the heart beating. + 'install-plugin', // Installing a new plugin. + 'install-theme', // Installing a new theme. + 'search-plugins', // Searching in the list of plugins. + 'search-install-plugins', // Searching for a plugin in the plugin install screen. + 'update-plugin', // Update an existing plugin. + 'update-theme', // Update an existing theme. + ); + + /** + * Filters the array of protected AJAX actions. + * + * This filter is only fired when doing AJAX and the AJAX request has an 'action' property. + * + * @since 5.1.0 + * + * @param array $actions_to_protect Array of strings with AJAX actions to protect. + */ + $actions_to_protect = (array) apply_filters( 'wp_protected_ajax_actions', $actions_to_protect ); + + if ( ! in_array( $_REQUEST['action'], $actions_to_protect, true ) ) { + return false; + } + + return true; +} + /** * Determines whether the current request is a WordPress cron request. * diff --git a/src/wp-includes/ms-load.php b/src/wp-includes/ms-load.php index 932ae76ca5..4f630cce27 100644 --- a/src/wp-includes/ms-load.php +++ b/src/wp-includes/ms-load.php @@ -52,6 +52,15 @@ function wp_get_active_network_plugins() { $plugins[] = WP_PLUGIN_DIR . '/' . $plugin; } } + + /* + * Remove plugins from the list of active plugins when we're on an endpoint + * that should be protected against WSODs and the plugin is paused. + */ + if ( is_protected_endpoint() ) { + $plugins = wp_skip_paused_plugins( $plugins ); + } + return $plugins; } diff --git a/src/wp-includes/template-loader.php b/src/wp-includes/template-loader.php index 0d2bafae44..0879798f55 100644 --- a/src/wp-includes/template-loader.php +++ b/src/wp-includes/template-loader.php @@ -4,7 +4,7 @@ * * @package WordPress */ -if ( defined( 'WP_USE_THEMES' ) && WP_USE_THEMES ) { +if ( wp_using_themes() ) { /** * Fires before determining which template to load. * @@ -44,7 +44,7 @@ elseif ( is_trackback() ) : return; endif; -if ( defined( 'WP_USE_THEMES' ) && WP_USE_THEMES ) : +if ( wp_using_themes() ) : $template = false; if ( is_embed() && $template = get_embed_template() ) : elseif ( is_404() && $template = get_404_template() ) : diff --git a/src/wp-settings.php b/src/wp-settings.php index ca7d0b1a14..781d8e6ca1 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -17,9 +17,15 @@ define( 'WPINC', 'wp-includes' ); // Include files required for initialization. require( ABSPATH . WPINC . '/load.php' ); +require( ABSPATH . WPINC . '/class-wp-paused-extensions-storage.php' ); +require( ABSPATH . WPINC . '/class-wp-shutdown-handler.php' ); +require( ABSPATH . WPINC . '/error-protection.php' ); 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 @@ -474,14 +480,12 @@ $GLOBALS['wp_locale_switcher'] = new WP_Locale_Switcher(); $GLOBALS['wp_locale_switcher']->init(); // Load the functions for the active theme, for both parent and child theme if applicable. -if ( ! wp_installing() || 'wp-activate.php' === $pagenow ) { - if ( TEMPLATEPATH !== STYLESHEETPATH && file_exists( STYLESHEETPATH . '/functions.php' ) ) { - include( STYLESHEETPATH . '/functions.php' ); - } - if ( file_exists( TEMPLATEPATH . '/functions.php' ) ) { - include( TEMPLATEPATH . '/functions.php' ); +foreach ( wp_get_active_and_valid_themes() as $theme ) { + if ( file_exists( $theme . '/functions.php' ) ) { + include $theme . '/functions.php'; } } +unset( $theme ); /** * Fires after the theme is loaded. @@ -526,3 +530,12 @@ if ( is_multisite() ) { * @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 ); +}