WordPress.org

Make WordPress Core

Ticket #46130: 46130.2.diff

File 46130.2.diff, 87.5 KB (added by TimothyBlynJacobs, 15 months ago)
  • src/wp-admin/css/list-tables.css

    diff --git a/src/wp-admin/css/list-tables.css b/src/wp-admin/css/list-tables.css
    index 87cc3f2f8a..91b1a2c9ce 100644
    a b ul.cat-checklist { 
    13101310        text-decoration: underline;
    13111311}
    13121312
     1313.plugins tr.paused th.check-column {
     1314        border-left: 4px solid #d54e21;
     1315}
     1316
     1317.plugins tr.paused th,
     1318.plugins tr.paused td {
     1319        background-color: #fef7f1;
     1320}
     1321
     1322.plugins tr.paused .plugin-title,
     1323.plugins .paused .dashicons-warning {
     1324        color: #dc3232;
     1325}
     1326
     1327.plugins .paused .error-display p,
     1328.plugins .paused .error-display code {
     1329        font-size: 90%;
     1330        font-style: italic;
     1331        color: rgb( 0, 0, 0, 0.7 );
     1332}
     1333
     1334.plugins .resume-link {
     1335        color: #dc3232;
     1336}
     1337
    13131338.plugin-card .update-now:before {
    13141339        color: #f56e28;
    13151340        content: "\f463";
  • src/wp-admin/includes/admin-filters.php

    diff --git a/src/wp-admin/includes/admin-filters.php b/src/wp-admin/includes/admin-filters.php
    index 4da6469d5f..3407e93171 100644
    a b add_action( 'load-plugins.php', 'wp_plugin_update_rows', 20 ); // After wp_updat 
    117117add_action( 'load-themes.php', 'wp_theme_update_rows', 20 ); // After wp_update_themes() is called.
    118118
    119119add_action( 'admin_notices', 'update_nag', 3 );
     120add_action( 'admin_notices', 'paused_plugins_notice', 5 );
     121add_action( 'admin_notices', 'paused_themes_notice', 5 );
    120122add_action( 'admin_notices', 'maintenance_nag', 10 );
    121123
    122124add_filter( 'update_footer', 'core_update_footer' );
  • src/wp-admin/includes/class-wp-plugins-list-table.php

    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 fa40ba9a36..6ddae381bd 100644
    a b class WP_Plugins_List_Table extends WP_List_Table { 
    4040                );
    4141
    4242                $status = 'all';
    43                 if ( isset( $_REQUEST['plugin_status'] ) && in_array( $_REQUEST['plugin_status'], array( 'active', 'inactive', 'recently_activated', 'upgrade', 'mustuse', 'dropins', 'search' ) ) ) {
     43                if ( isset( $_REQUEST['plugin_status'] ) && in_array( $_REQUEST['plugin_status'], array( 'active', 'inactive', 'recently_activated', 'upgrade', 'mustuse', 'dropins', 'search', 'paused' ) ) ) {
    4444                        $status = $_REQUEST['plugin_status'];
    4545                }
    4646
    class WP_Plugins_List_Table extends WP_List_Table { 
    9999                        'upgrade'            => array(),
    100100                        'mustuse'            => array(),
    101101                        'dropins'            => array(),
     102                        'paused'             => array(),
    102103                );
    103104
    104105                $screen = $this->screen;
    class WP_Plugins_List_Table extends WP_List_Table { 
    209210                                if ( $show_network_active ) {
    210211                                        // On the non-network screen, show network-active plugins if allowed
    211212                                        $plugins['active'][ $plugin_file ] = $plugin_data;
     213                                        if ( is_plugin_paused( $plugin_file ) ) {
     214                                                $plugins['paused'][ $plugin_file ] = $plugin_data;
     215                                        }
    212216                                } else {
    213217                                        // On the non-network screen, filter out network-active plugins
    214218                                        unset( $plugins['all'][ $plugin_file ] );
    class WP_Plugins_List_Table extends WP_List_Table { 
    218222                                // On the non-network screen, populate the active list with plugins that are individually activated
    219223                                // On the network-admin screen, populate the active list with plugins that are network activated
    220224                                $plugins['active'][ $plugin_file ] = $plugin_data;
     225                                if ( is_plugin_paused( $plugin_file ) ) {
     226                                        $plugins['paused'][ $plugin_file ] = $plugin_data;
     227                                }
    221228                        } else {
    222229                                if ( isset( $recently_activated[ $plugin_file ] ) ) {
    223230                                        // Populate the recently activated list with plugins that have been recently activated
    class WP_Plugins_List_Table extends WP_List_Table { 
    445452                                        /* translators: %s: plugin count */
    446453                                        $text = _n( 'Drop-ins <span class="count">(%s)</span>', 'Drop-ins <span class="count">(%s)</span>', $count );
    447454                                        break;
     455                                case 'paused':
     456                                        /* translators: %s: plugin count */
     457                                        $text = _n( 'Paused <span class="count">(%s)</span>', 'Paused <span class="count">(%s)</span>', $count );
     458                                        break;
    448459                                case 'upgrade':
    449460                                        /* translators: %s: plugin count */
    450461                                        $text = _n( 'Update Available <span class="count">(%s)</span>', 'Update Available <span class="count">(%s)</span>', $count );
    class WP_Plugins_List_Table extends WP_List_Table { 
    633644                                                /* translators: %s: plugin name */
    634645                                                $actions['deactivate'] = '<a href="' . wp_nonce_url( 'plugins.php?action=deactivate&amp;plugin=' . urlencode( $plugin_file ) . '&amp;plugin_status=' . $context . '&amp;paged=' . $page . '&amp;s=' . $s, 'deactivate-plugin_' . $plugin_file ) . '" aria-label="' . esc_attr( sprintf( _x( 'Network Deactivate %s', 'plugin' ), $plugin_data['Name'] ) ) . '">' . __( 'Network Deactivate' ) . '</a>';
    635646                                        }
     647                                        if ( current_user_can( 'manage_network_plugins' ) && count_paused_plugin_sites_for_network( $plugin_file ) ) {
     648                                                /* translators: %s: plugin name */
     649                                                $actions['resume'] = '<a class="resume-link" href="' . wp_nonce_url( 'plugins.php?action=resume&amp;plugin=' . urlencode( $plugin_file ) . '&amp;plugin_status=' . $context . '&amp;paged=' . $page . '&amp;s=' . $s, 'resume-plugin_' . $plugin_file ) . '" aria-label="' . esc_attr( sprintf( _x( 'Network Resume %s', 'plugin' ), $plugin_data['Name'] ) ) . '">' . __( 'Network Resume' ) . '</a>';
     650                                        }
    636651                                } else {
    637652                                        if ( current_user_can( 'manage_network_plugins' ) ) {
    638653                                                /* translators: %s: plugin name */
    639654                                                $actions['activate'] = '<a href="' . wp_nonce_url( 'plugins.php?action=activate&amp;plugin=' . urlencode( $plugin_file ) . '&amp;plugin_status=' . $context . '&amp;paged=' . $page . '&amp;s=' . $s, 'activate-plugin_' . $plugin_file ) . '" class="edit" aria-label="' . esc_attr( sprintf( _x( 'Network Activate %s', 'plugin' ), $plugin_data['Name'] ) ) . '">' . __( 'Network Activate' ) . '</a>';
    640655                                        }
     656                                        if ( current_user_can( 'manage_network_plugins' ) && count_paused_plugin_sites_for_network( $plugin_file ) ) {
     657                                                /* translators: %s: plugin name */
     658                                                $actions['resume'] = '<a class="resume-link" href="' . wp_nonce_url( 'plugins.php?action=resume&amp;plugin=' . urlencode( $plugin_file ) . '&amp;plugin_status=' . $context . '&amp;paged=' . $page . '&amp;s=' . $s, 'resume-plugin_' . $plugin_file ) . '" aria-label="' . esc_attr( sprintf( _x( 'Network Resume %s', 'plugin' ), $plugin_data['Name'] ) ) . '">' . __( 'Network Resume' ) . '</a>';
     659                                        }
    641660                                        if ( current_user_can( 'delete_plugins' ) && ! is_plugin_active( $plugin_file ) ) {
    642661                                                /* translators: %s: plugin name */
    643662                                                $actions['delete'] = '<a href="' . wp_nonce_url( 'plugins.php?action=delete-selected&amp;checked[]=' . urlencode( $plugin_file ) . '&amp;plugin_status=' . $context . '&amp;paged=' . $page . '&amp;s=' . $s, 'bulk-plugins' ) . '" class="delete" aria-label="' . esc_attr( sprintf( _x( 'Delete %s', 'plugin' ), $plugin_data['Name'] ) ) . '">' . __( 'Delete' ) . '</a>';
    class WP_Plugins_List_Table extends WP_List_Table { 
    648667                                        $actions = array(
    649668                                                'network_active' => __( 'Network Active' ),
    650669                                        );
     670                                        if ( ! $restrict_network_only && current_user_can( 'resume_plugin', $plugin_file ) && is_plugin_paused( $plugin_file ) ) {
     671                                                /* translators: %s: plugin name */
     672                                                $actions['resume'] = '<a class="resume-link" href="' . wp_nonce_url( 'plugins.php?action=resume&amp;plugin=' . urlencode( $plugin_file ) . '&amp;plugin_status=' . $context . '&amp;paged=' . $page . '&amp;s=' . $s, 'resume-plugin_' . $plugin_file ) . '" aria-label="' . esc_attr( sprintf( _x( 'Resume %s', 'plugin' ), $plugin_data['Name'] ) ) . '">' . __( 'Resume' ) . '</a>';
     673                                        }
    651674                                } elseif ( $restrict_network_only ) {
    652675                                        $actions = array(
    653676                                                'network_only' => __( 'Network Only' ),
    class WP_Plugins_List_Table extends WP_List_Table { 
    657680                                                /* translators: %s: plugin name */
    658681                                                $actions['deactivate'] = '<a href="' . wp_nonce_url( 'plugins.php?action=deactivate&amp;plugin=' . urlencode( $plugin_file ) . '&amp;plugin_status=' . $context . '&amp;paged=' . $page . '&amp;s=' . $s, 'deactivate-plugin_' . $plugin_file ) . '" aria-label="' . esc_attr( sprintf( _x( 'Deactivate %s', 'plugin' ), $plugin_data['Name'] ) ) . '">' . __( 'Deactivate' ) . '</a>';
    659682                                        }
     683                                        if ( current_user_can( 'resume_plugin', $plugin_file ) && is_plugin_paused( $plugin_file ) ) {
     684                                                /* translators: %s: plugin name */
     685                                                $actions['resume'] = '<a class="resume-link" href="' . wp_nonce_url( 'plugins.php?action=resume&amp;plugin=' . urlencode( $plugin_file ) . '&amp;plugin_status=' . $context . '&amp;paged=' . $page . '&amp;s=' . $s, 'resume-plugin_' . $plugin_file ) . '" aria-label="' . esc_attr( sprintf( _x( 'Resume %s', 'plugin' ), $plugin_data['Name'] ) ) . '">' . __( 'Resume' ) . '</a>';
     686                                        }
    660687                                } else {
    661688                                        if ( current_user_can( 'activate_plugin', $plugin_file ) ) {
    662689                                                /* translators: %s: plugin name */
    class WP_Plugins_List_Table extends WP_List_Table { 
    764791                        $class .= ' update';
    765792                }
    766793
     794                $paused = is_plugin_paused( $plugin_file );
     795
     796                if ( $paused ) {
     797                        $class .= ' paused';
     798                }
     799
    767800                $plugin_slug = isset( $plugin_data['slug'] ) ? $plugin_data['slug'] : sanitize_title( $plugin_name );
    768801                printf(
    769802                        '<tr class="%s" data-slug="%s" data-plugin="%s">',
    class WP_Plugins_List_Table extends WP_List_Table { 
    845878                                         * @param array    $plugin_data An array of plugin data.
    846879                                         * @param string   $status      Status of the plugin. Defaults are 'All', 'Active',
    847880                                         *                              'Inactive', 'Recently Activated', 'Upgrade', 'Must-Use',
    848                                          *                              'Drop-ins', 'Search'.
     881                                         *                              'Drop-ins', 'Search', 'Paused'.
    849882                                         */
    850883                                        $plugin_meta = apply_filters( 'plugin_row_meta', $plugin_meta, $plugin_file, $plugin_data, $status );
    851884                                        echo implode( ' | ', $plugin_meta );
    852885
    853886                                        echo '</div>';
    854887
     888                                        if ( $paused ) {
     889                                                $notice_text = __( 'This plugin failed to load properly and is paused during recovery mode.' );
     890
     891                                                printf( '<p><span class="dashicons dashicons-warning"></span> <strong>%s</strong></p>', $notice_text );
     892
     893                                                $error = wp_get_plugin_error( $plugin_file );
     894
     895                                                if ( false !== $error ) {
     896                                                        printf( '<div class="error-display"><p>%s</p></div>', wp_get_plugin_error_description( $error ) );
     897                                                }
     898                                        }
     899
    855900                                        echo '</td>';
    856901                                        break;
    857902                                default:
    class WP_Plugins_List_Table extends WP_List_Table { 
    885930                 * @param array  $plugin_data An array of plugin data.
    886931                 * @param string $status      Status of the plugin. Defaults are 'All', 'Active',
    887932                 *                            'Inactive', 'Recently Activated', 'Upgrade', 'Must-Use',
    888                  *                            'Drop-ins', 'Search'.
     933                 *                            'Drop-ins', 'Search', 'Paused'.
    889934                 */
    890935                do_action( 'after_plugin_row', $plugin_file, $plugin_data, $status );
    891936
    class WP_Plugins_List_Table extends WP_List_Table { 
    901946                 * @param array  $plugin_data An array of plugin data.
    902947                 * @param string $status      Status of the plugin. Defaults are 'All', 'Active',
    903948                 *                            'Inactive', 'Recently Activated', 'Upgrade', 'Must-Use',
    904                  *                            'Drop-ins', 'Search'.
     949                 *                            'Drop-ins', 'Search', 'Paused'.
    905950                 */
    906951                do_action( "after_plugin_row_{$plugin_file}", $plugin_file, $plugin_data, $status );
    907952        }
  • src/wp-admin/includes/plugin.php

    diff --git a/src/wp-admin/includes/plugin.php b/src/wp-admin/includes/plugin.php
    index 5873a33466..01de19ce70 100644
    a b function get_dropins() { 
    468468 */
    469469function _get_dropins() {
    470470        $dropins = array(
    471                 'advanced-cache.php' => array( __( 'Advanced caching plugin.' ), 'WP_CACHE' ), // WP_CACHE
    472                 'db.php'             => array( __( 'Custom database class.' ), true ), // auto on load
    473                 'db-error.php'       => array( __( 'Custom database error message.' ), true ), // auto on error
    474                 'install.php'        => array( __( 'Custom installation script.' ), true ), // auto on installation
    475                 'maintenance.php'    => array( __( 'Custom maintenance message.' ), true ), // auto on maintenance
    476                 'object-cache.php'   => array( __( 'External object cache.' ), true ), // auto on load
     471                'advanced-cache.php'           => array( __( 'Advanced caching plugin.' ), 'WP_CACHE' ), // WP_CACHE
     472                'db.php'                       => array( __( 'Custom database class.' ), true ), // auto on load
     473                'db-error.php'                 => array( __( 'Custom database error message.' ), true ), // auto on error
     474                'install.php'                  => array( __( 'Custom installation script.' ), true ), // auto on installation
     475                'maintenance.php'              => array( __( 'Custom maintenance message.' ), true ), // auto on maintenance
     476                'object-cache.php'             => array( __( 'External object cache.' ), true ), // auto on load
     477                'php-error.php'                => array( __( 'Custom PHP error message.' ), true ), // auto on error
     478                'fatal-error-handler.php'      => array( __( 'Custom PHP fatal error handler.' ), true ), // auto on error
     479                'recovery-mode-controller.php' => array( __( 'Custom recovery mode controller.' ), true ), // auto on error
    477480        );
    478481
    479482        if ( is_multisite() ) {
    function wp_add_privacy_policy_content( $plugin_name, $policy_text ) { 
    21012104
    21022105        WP_Privacy_Policy_Content::add( $plugin_name, $policy_text );
    21032106}
     2107
     2108/**
     2109 * Determines whether a plugin is technically active but was paused while
     2110 * loading.
     2111 *
     2112 * For more information on this and similar theme functions, check out
     2113 * the {@link https://developer.wordpress.org/themes/basics/conditional-tags/
     2114 * Conditional Tags} article in the Theme Developer Handbook.
     2115 *
     2116 * @since 5.2.0
     2117 *
     2118 * @param string $plugin Path to the plugin file relative to the plugins directory.
     2119 * @return bool True, if in the list of paused plugins. False, not in the list.
     2120 */
     2121function is_plugin_paused( $plugin ) {
     2122        if ( ! isset( $GLOBALS['_paused_plugins'] ) ) {
     2123                return false;
     2124        }
     2125
     2126        if ( ! is_plugin_active( $plugin ) && ! is_plugin_active_for_network( $plugin ) ) {
     2127                return false;
     2128        }
     2129
     2130        list( $plugin ) = explode( '/', $plugin );
     2131
     2132        return array_key_exists( $plugin, $GLOBALS['_paused_plugins'] );
     2133}
     2134
     2135/**
     2136 * Gets the error that was recorded for a paused plugin.
     2137 *
     2138 * @since 5.2.0
     2139 *
     2140 * @param string $plugin Path to the plugin file relative to the plugins
     2141 *                       directory.
     2142 * @return array|false Array of error information as it was returned by
     2143 *                     `error_get_last()`, or false if none was recorded.
     2144 */
     2145function wp_get_plugin_error( $plugin ) {
     2146        if ( ! isset( $GLOBALS['_paused_plugins'] ) ) {
     2147                return false;
     2148        }
     2149
     2150        list( $plugin ) = explode( '/', $plugin );
     2151
     2152        if ( ! array_key_exists( $plugin, $GLOBALS['_paused_plugins'] ) ) {
     2153                return false;
     2154        }
     2155
     2156        return $GLOBALS['_paused_plugins'][ $plugin ];
     2157}
     2158
     2159/**
     2160 * Get a human readable description of the plugin error.
     2161 *
     2162 * @since 5.2.0
     2163 *
     2164 * @param array $error Error from {@see wp_get_plugin_error()}
     2165 *
     2166 * @return string Formatted error description.
     2167 */
     2168function wp_get_plugin_error_description( $error ) {
     2169        $constants   = get_defined_constants( true );
     2170        $constants   = isset( $constants['Core'] ) ? $constants['Core'] : $constants['internal'];
     2171        $core_errors = array();
     2172
     2173        foreach ( $constants as $constant => $value ) {
     2174                if ( 0 === strpos( $constant, 'E_' ) ) {
     2175                        $core_errors[ $value ] = $constant;
     2176                }
     2177        }
     2178
     2179        if ( isset( $core_errors[ $error['type'] ] ) ) {
     2180                $error['type'] = $core_errors[ $error['type'] ];
     2181        }
     2182
     2183        /* translators: 1: error type, 2: error line number, 3: error file name, 4: error message */
     2184        $error_message = __( 'The plugin caused an error of type %1$s in line %2$s of the file %3$s. Error message: %4$s' );
     2185
     2186        return sprintf(
     2187                $error_message,
     2188                "<code>{$error['type']}</code>",
     2189                "<code>{$error['line']}</code>",
     2190                "<code>{$error['file']}</code>",
     2191                "<code>{$error['message']}</code>"
     2192        );
     2193}
     2194
     2195/**
     2196 * Gets the number of sites on which a specific plugin is paused.
     2197 *
     2198 * @since 5.2.0
     2199 *
     2200 * @param string $plugin Path to the plugin file relative to the plugins directory.
     2201 * @return int Site count.
     2202 */
     2203function count_paused_plugin_sites_for_network( $plugin ) {
     2204        if ( ! is_multisite() ) {
     2205                return is_plugin_paused( $plugin ) ? 1 : 0;
     2206        }
     2207
     2208        list( $plugin ) = explode( '/', $plugin );
     2209
     2210        $query_args = array(
     2211                'count'      => true,
     2212                'number'     => 0,
     2213                'network_id' => get_current_network_id(),
     2214                'meta_query' => array(
     2215                        wp_paused_extensions()->get_site_meta_query_clause( 'plugin', $plugin ),
     2216                ),
     2217        );
     2218
     2219        return get_sites( $query_args );
     2220}
     2221
     2222/**
     2223 * Tries to resume a single plugin.
     2224 *
     2225 * If a redirect was provided, we first ensure the plugin does not throw fatal
     2226 * errors anymore.
     2227 *
     2228 * The way it works is by setting the redirection to the error before trying to
     2229 * include the plugin file. If the plugin fails, then the redirection will not
     2230 * be overwritten with the success message and the plugin will not be resumed.
     2231 *
     2232 * @since 5.2.0
     2233 *
     2234 * @param string $plugin       Single plugin to resume.
     2235 * @param string $redirect     Optional. URL to redirect to. Default empty string.
     2236 * @param bool   $network_wide Optional. Whether to resume the plugin for the entire
     2237 *                             network. Default false.
     2238 * @return bool|WP_Error True on success, false if `$plugin` was not paused,
     2239 *                       `WP_Error` on failure.
     2240 */
     2241function resume_plugin( $plugin, $redirect = '', $network_wide = false ) {
     2242        /*
     2243         * We'll override this later if the plugin could be included without
     2244         * creating a fatal error.
     2245         */
     2246        if ( ! empty( $redirect ) ) {
     2247                wp_redirect(
     2248                        add_query_arg(
     2249                                '_error_nonce',
     2250                                wp_create_nonce( 'plugin-resume-error_' . $plugin ),
     2251                                $redirect
     2252                        )
     2253                );
     2254
     2255                // Load the plugin to test whether it throws a fatal error.
     2256                ob_start();
     2257                plugin_sandbox_scrape( $plugin );
     2258                ob_clean();
     2259        }
     2260
     2261        $result = wp_forget_extension_error( 'plugin', $plugin, $network_wide );
     2262
     2263        if ( ! $result ) {
     2264                return new WP_Error(
     2265                        'could_not_resume_plugin',
     2266                        __( 'Could not resume the plugin.' )
     2267                );
     2268        }
     2269
     2270        return true;
     2271}
     2272
     2273/**
     2274 * Renders an admin notice in case some plugins have been paused due to errors.
     2275 *
     2276 * @since 5.2.0
     2277 */
     2278function paused_plugins_notice() {
     2279        if ( 'plugins.php' === $GLOBALS['pagenow'] ) {
     2280                return;
     2281        }
     2282
     2283        if ( ! current_user_can( 'deactivate_plugins' ) ) {
     2284                return;
     2285        }
     2286
     2287        if ( ! isset( $GLOBALS['_paused_plugins'] ) || empty( $GLOBALS['_paused_plugins'] ) ) {
     2288                return;
     2289        }
     2290
     2291        printf(
     2292                '<div class="notice notice-error"><p><strong>%s</strong><br>%s</p><p>%s</p></div>',
     2293                __( 'One or more plugins failed to load properly.' ),
     2294                __( 'You can find more details and make changes on the Plugins screen.' ),
     2295                sprintf(
     2296                        '<a href="%s">%s</a>',
     2297                        admin_url( 'plugins.php?plugin_status=paused' ),
     2298                        'Go to the Plugins screen'
     2299                )
     2300        );
     2301}
  • src/wp-admin/includes/theme.php

    diff --git a/src/wp-admin/includes/theme.php b/src/wp-admin/includes/theme.php
    index 91a3577906..a24f4dc938 100644
    a b function customize_themes_print_templates() { 
    768768        </script>
    769769        <?php
    770770}
     771
     772/**
     773 * Determines whether a theme is technically active but was paused while
     774 * loading.
     775 *
     776 * For more information on this and similar theme functions, check out
     777 * the {@link https://developer.wordpress.org/themes/basics/conditional-tags/
     778 * Conditional Tags} article in the Theme Developer Handbook.
     779 *
     780 * @since 5.2.0
     781 *
     782 * @param string $theme Path to the theme directory relative to the themes directory.
     783 * @return bool True, if in the list of paused themes. False, not in the list.
     784 */
     785function is_theme_paused( $theme ) {
     786        if ( ! isset( $GLOBALS['_paused_themes'] ) ) {
     787                return false;
     788        }
     789
     790        if ( get_stylesheet() !== $theme && get_template() !== $theme ) {
     791                return false;
     792        }
     793
     794        return array_key_exists( $theme, $GLOBALS['_paused_themes'] );
     795}
     796
     797/**
     798 * Gets the error that was recorded for a paused theme.
     799 *
     800 * @since 5.2.0
     801 *
     802 * @param string $theme Path to the theme directory relative to the themes
     803 *                      directory.
     804 * @return array|false Array of error information as it was returned by
     805 *                     `error_get_last()`, or false if none was recorded.
     806 */
     807function wp_get_theme_error( $theme ) {
     808        if ( ! isset( $GLOBALS['_paused_themes'] ) ) {
     809                return false;
     810        }
     811
     812        if ( ! array_key_exists( $theme, $GLOBALS['_paused_themes'] ) ) {
     813                return false;
     814        }
     815
     816        return $GLOBALS['_paused_themes'][ $theme ];
     817}
     818
     819/**
     820 * Gets the number of sites on which a specific theme is paused.
     821 *
     822 * @since 5.2.0
     823 *
     824 * @param string $theme Path to the theme directory relative to the themes directory.
     825 * @return int Site count.
     826 */
     827function count_paused_theme_sites_for_network( $theme ) {
     828        if ( ! is_multisite() ) {
     829                return is_theme_paused( $theme ) ? 1 : 0;
     830        }
     831
     832        $query_args = array(
     833                'count'      => true,
     834                'number'     => 0,
     835                'network_id' => get_current_network_id(),
     836                'meta_query' => array(
     837                        wp_paused_extensions()->get_site_meta_query_clause( 'theme', $theme ),
     838                ),
     839        );
     840
     841        return get_sites( $query_args );
     842}
     843
     844/**
     845 * Tries to resume a single theme.
     846 *
     847 * @since 5.2.0
     848 *
     849 * @param string $theme Single theme to resume.
     850 * @return bool|WP_Error True on success, false if `$theme` was not paused,
     851 *                       `WP_Error` on failure.
     852 */
     853function resume_theme( $theme ) {
     854        $result = wp_forget_extension_error( 'theme', $theme );
     855
     856        if ( ! $result ) {
     857                return new WP_Error(
     858                        'could_not_resume_theme',
     859                        __( 'Could not resume the theme.' )
     860                );
     861        }
     862
     863        return true;
     864}
     865
     866/**
     867 * Renders an admin notice in case some themes have been paused due to errors.
     868 *
     869 * @since 5.2.0
     870 */
     871function paused_themes_notice() {
     872        if ( 'themes.php' === $GLOBALS['pagenow'] ) {
     873                return;
     874        }
     875
     876        if ( ! current_user_can( 'switch_themes' ) ) {
     877                return;
     878        }
     879
     880        if ( ! isset( $GLOBALS['_paused_themes'] ) || empty( $GLOBALS['_paused_themes'] ) ) {
     881                return;
     882        }
     883
     884        printf(
     885                '<div class="notice notice-error"><p><strong>%s</strong><br>%s</p><p>%s</p></div>',
     886                __( 'One or more themes failed to load properly.' ),
     887                __( 'You can find more details and make changes on the Themes screen.' ),
     888                sprintf(
     889                        '<a href="%s">%s</a>',
     890                        admin_url( 'themes.php' ),
     891                        'Go to the Themes screen'
     892                )
     893        );
     894}
  • src/wp-admin/plugins.php

    diff --git a/src/wp-admin/plugins.php b/src/wp-admin/plugins.php
    index c80e96831f..6c41e31b2f 100644
    a b if ( $action ) { 
    389389                        }
    390390                        break;
    391391
     392                case 'resume':
     393                        if ( ! current_user_can( 'resume_plugin', $plugin ) ) {
     394                                wp_die( __( 'Sorry, you are not allowed to resume this plugin.' ) );
     395                        }
     396
     397                        if ( is_multisite() && ! is_network_admin() && is_network_only_plugin( $plugin ) ) {
     398                                wp_redirect( self_admin_url( "plugins.php?plugin_status=$status&paged=$page&s=$s" ) );
     399                                exit;
     400                        }
     401
     402                        check_admin_referer( 'resume-plugin_' . $plugin );
     403
     404                        $result = resume_plugin( $plugin, self_admin_url( 'plugins.php?error=resuming' ), is_network_admin() );
     405
     406                        if ( is_wp_error( $result ) ) {
     407                                wp_die( $result );
     408                        }
     409
     410                        wp_redirect( self_admin_url( "plugins.php?resume=true&plugin_status=$status&paged=$page&s=$s" ) );
     411                        exit;
     412
    392413                default:
    393414                        if ( isset( $_POST['checked'] ) ) {
    394415                                check_admin_referer( 'bulk-plugins' );
    if ( isset( $_GET['error'] ) ) : 
    488509                        $_GET['charsout']
    489510                );
    490511                $errmsg .= ' ' . __( 'If you notice &#8220;headers already sent&#8221; messages, problems with syndication feeds or other issues, try deactivating or removing this plugin.' );
     512        } elseif ( 'resuming' === $_GET['error'] ) {
     513                $errmsg = __( 'Plugin could not be resumed because it triggered a <strong>fatal error</strong>.' );
    491514        } else {
    492515                $errmsg = __( 'Plugin could not be activated because it triggered a <strong>fatal error</strong>.' );
    493516        }
    elseif ( isset( $_GET['deleted'] ) ) : 
    541564        <div id="message" class="updated notice is-dismissible"><p><?php _e( 'Selected plugins <strong>deactivated</strong>.' ); ?></p></div>
    542565<?php elseif ( 'update-selected' == $action ) : ?>
    543566        <div id="message" class="updated notice is-dismissible"><p><?php _e( 'All selected plugins are up to date.' ); ?></p></div>
     567<?php elseif ( isset( $_GET['resume'] ) ) : ?>
     568        <div id="message" class="updated notice is-dismissible"><p><?php _e( 'Plugin <strong>resumed</strong>.' ); ?></p></div>
    544569<?php endif; ?>
    545570
    546571<div class="wrap">
  • src/wp-admin/themes.php

    diff --git a/src/wp-admin/themes.php b/src/wp-admin/themes.php
    index 494e952184..754b75808d 100644
    a b if ( current_user_can( 'switch_themes' ) && isset( $_GET['action'] ) ) { 
    3333                switch_theme( $theme->get_stylesheet() );
    3434                wp_redirect( admin_url( 'themes.php?activated=true' ) );
    3535                exit;
     36        } elseif ( 'resume' === $_GET['action'] ) {
     37                check_admin_referer( 'resume-theme_' . $_GET['stylesheet'] );
     38                $theme = wp_get_theme( $_GET['stylesheet'] );
     39
     40                if ( ! current_user_can( 'resume_themes' ) ) {
     41                        wp_die(
     42                                '<h1>' . __( 'You need a higher level of permission.' ) . '</h1>' .
     43                                '<p>' . __( 'Sorry, you are not allowed to resume this theme.' ) . '</p>',
     44                                403
     45                        );
     46                }
     47
     48                $result = resume_theme( $theme->get_stylesheet() );
     49
     50                if ( is_wp_error( $result ) ) {
     51                        wp_die( $result );
     52                }
     53
     54                wp_redirect( admin_url( 'themes.php?resumed=true' ) );
     55                exit;
    3656        } elseif ( 'delete' == $_GET['action'] ) {
    3757                check_admin_referer( 'delete-theme_' . $_GET['stylesheet'] );
    3858                $theme = wp_get_theme( $_GET['stylesheet'] );
    if ( ! validate_current_theme() || isset( $_GET['broken'] ) ) { 
    195215        ?>
    196216        <div id="message4" class="error"><p><?php _e( 'You cannot delete a theme while it has an active child theme.' ); ?></p></div>
    197217        <?php
     218} elseif ( isset( $_GET['resumed'] ) ) {
     219        ?>
     220        <div id="message5" class="updated notice is-dismissible"><p><?php _e( 'Theme resumed.' ); ?></p></div>
     221        <?php
    198222}
    199223
    200224$ct = wp_get_theme();
    if ( ! is_multisite() && current_user_can( 'edit_themes' ) && $broken_themes = w 
    348372<p><?php _e( 'The following themes are installed but incomplete.' ); ?></p>
    349373
    350374        <?php
     375        $can_resume  = current_user_can( 'resume_themes' );
    351376        $can_delete  = current_user_can( 'delete_themes' );
    352377        $can_install = current_user_can( 'install_themes' );
    353378        ?>
    if ( ! is_multisite() && current_user_can( 'edit_themes' ) && $broken_themes = w 
    355380        <tr>
    356381                <th><?php _ex( 'Name', 'theme name' ); ?></th>
    357382                <th><?php _e( 'Description' ); ?></th>
     383                <?php if ( $can_resume ) { ?>
     384                        <td></td>
     385                <?php } ?>
    358386                <?php if ( $can_delete ) { ?>
    359387                        <td></td>
    360388                <?php } ?>
    if ( ! is_multisite() && current_user_can( 'edit_themes' ) && $broken_themes = w 
    367395                        <td><?php echo $broken_theme->get( 'Name' ) ? $broken_theme->display( 'Name' ) : $broken_theme->get_stylesheet(); ?></td>
    368396                        <td><?php echo $broken_theme->errors()->get_error_message(); ?></td>
    369397                        <?php
     398                        if ( $can_resume ) {
     399                                if ( 'theme_paused' === $broken_theme->errors()->get_error_code() ) {
     400                                        $stylesheet = $broken_theme->get_stylesheet();
     401                                        $resume_url = add_query_arg(
     402                                                array(
     403                                                        'action'     => 'resume',
     404                                                        'stylesheet' => urlencode( $stylesheet ),
     405                                                ),
     406                                                admin_url( 'themes.php' )
     407                                        );
     408                                        $resume_url = wp_nonce_url( $resume_url, 'resume-theme_' . $stylesheet );
     409                                        ?>
     410                                        <td><a href="<?php echo esc_url( $resume_url ); ?>" class="button resume-theme"><?php _e( 'Resume' ); ?></a></td>
     411                                        <?php
     412                                } else {
     413                                        ?>
     414                                        <td></td>
     415                                        <?php
     416                                }
     417                        }
     418
    370419                        if ( $can_delete ) {
    371420                                $stylesheet = $broken_theme->get_stylesheet();
    372421                                $delete_url = add_query_arg(
  • src/wp-includes/capabilities.php

    diff --git a/src/wp-includes/capabilities.php b/src/wp-includes/capabilities.php
    index c192639608..17d0fd1869 100644
    a b function map_meta_cap( $cap, $user_id ) { 
    464464                                }
    465465                        }
    466466                        break;
     467                case 'resume_plugin':
     468                        $caps[] = 'resume_plugins';
     469                        break;
    467470                case 'delete_user':
    468471                case 'delete_users':
    469472                        // If multisite only super admins can delete users.
    function wp_maybe_grant_install_languages_cap( $allcaps ) { 
    950953
    951954        return $allcaps;
    952955}
     956
     957/**
     958 * Filters the user capabilities to grant the 'resume_plugins' and 'resume_themes' capabilities as necessary.
     959 *
     960 * @since 5.2.0
     961 *
     962 * @param bool[] $allcaps An array of all the user's capabilities.
     963 * @return bool[] Filtered array of the user's capabilities.
     964 */
     965function wp_maybe_grant_resume_extensions_caps( $allcaps ) {
     966        // Even in a multisite, regular administrators should be able to resume plugins.
     967        if ( ! empty( $allcaps['activate_plugins'] ) ) {
     968                $allcaps['resume_plugins'] = true;
     969        }
     970
     971        // Even in a multisite, regular administrators should be able to resume themes.
     972        if ( ! empty( $allcaps['switch_themes'] ) ) {
     973                $allcaps['resume_themes'] = true;
     974        }
     975
     976        return $allcaps;
     977}
  • new file src/wp-includes/class-wp-fatal-error-handler.php

    diff --git a/src/wp-includes/class-wp-fatal-error-handler.php b/src/wp-includes/class-wp-fatal-error-handler.php
    new file mode 100644
    index 0000000000..8baa6d2f2a
    - +  
     1<?php
     2/**
     3 * Error Protection API: WP_Fatal_Error_Handler class
     4 *
     5 * @package WordPress
     6 * @since   5.2.0
     7 */
     8
     9/**
     10 * Core class used as the default shutdown handler for fatal errors.
     11 *
     12 * A drop-in 'fatal-error-handler.php' can be used to override the instance of this class and use a custom
     13 * implementation for the fatal error handler that WordPress registers. The custom class should extend this class and
     14 * can override its methods individually as necessary. The file must return the instance of the class that should be
     15 * registered.
     16 *
     17 * @since 5.2.0
     18 */
     19class WP_Fatal_Error_Handler {
     20
     21        /**
     22         * Runs the shutdown handler.
     23         *
     24         * This method is registered via `register_shutdown_function()`.
     25         *
     26         * @since 5.2.0
     27         */
     28        public function handle() {
     29                // Bail if WordPress executed successfully.
     30                if ( defined( 'WP_EXECUTION_SUCCEEDED' ) && WP_EXECUTION_SUCCEEDED ) {
     31                        return;
     32                }
     33
     34                try {
     35                        // Bail if no error found.
     36                        $error = $this->detect_error();
     37                        if ( ! $error ) {
     38                                return;
     39                        }
     40
     41                        if ( wp_is_recovery_mode() ) {
     42                                // If the error was stored and thus the extension paused,
     43                                // redirect the request to catch multiple errors in one go.
     44                                if ( $this->store_error( $error ) ) {
     45                                        $this->redirect_protected();
     46                                }
     47                        } else {
     48                                wp_recovery_mode()->handle_error( $error );
     49                        }
     50
     51                        // Display the PHP error template.
     52                        $this->display_error_template();
     53                } catch ( Exception $e ) {
     54                        // Catch exceptions and remain silent.
     55                }
     56        }
     57
     58        /**
     59         * Detects the error causing the crash if it should be handled.
     60         *
     61         * @since 5.2.0
     62         *
     63         * @return array|null Error that was triggered, or null if no error received or if the error should not be handled.
     64         */
     65        protected function detect_error() {
     66                $error = error_get_last();
     67
     68                // No error, just skip the error handling code.
     69                if ( null === $error ) {
     70                        return null;
     71                }
     72
     73                // Bail if this error should not be handled.
     74                if ( ! wp_should_handle_error( $error ) ) {
     75                        return null;
     76                }
     77
     78                return $error;
     79        }
     80
     81        /**
     82         * Stores the given error so that the extension causing it is paused.
     83         *
     84         * @since 5.2.0
     85         *
     86         * @param array $error Error that was triggered.
     87         *
     88         * @return bool True if the error was stored successfully, false otherwise.
     89         */
     90        protected function store_error( $error ) {
     91                return wp_record_extension_error( $error );
     92        }
     93
     94        /**
     95         * Redirects the current request to allow recovering multiple errors in one go.
     96         *
     97         * The redirection will only happen when on a protected endpoint.
     98         *
     99         * It must be ensured that this method is only called when an error actually occurred and will not occur on the
     100         * next request again. Otherwise it will create a redirect loop.
     101         *
     102         * @since 5.2.0
     103         */
     104        protected function redirect_protected() {
     105                // Pluggable is usually loaded after plugins, so we manually include it here for redirection functionality.
     106                if ( ! function_exists( 'wp_redirect' ) ) {
     107                        include ABSPATH . WPINC . '/pluggable.php';
     108                }
     109
     110                $scheme = is_ssl() ? 'https://' : 'http://';
     111
     112                $url = "{$scheme}{$_SERVER['HTTP_HOST']}{$_SERVER['REQUEST_URI']}";
     113                wp_redirect( $url );
     114                exit;
     115        }
     116
     117        /**
     118         * Displays the PHP error template and sends the HTTP status code, typically 500.
     119         *
     120         * A drop-in 'php-error.php' can be used as a custom template. This drop-in should control the HTTP status code and
     121         * print the HTML markup indicating that a PHP error occurred. Note that this drop-in may potentially be executed
     122         * very early in the WordPress bootstrap process, so any core functions used that are not part of
     123         * `wp-includes/load.php` should be checked for before being called.
     124         *
     125         * If no such drop-in is available, this will call {@see WP_Fatal_Error_Handler::display_default_error_template()}.
     126         *
     127         * @since 5.2.0
     128         */
     129        protected function display_error_template() {
     130                if ( defined( 'WP_CONTENT_DIR' ) ) {
     131                        // Load custom PHP error template, if present.
     132                        $php_error_pluggable = WP_CONTENT_DIR . '/php-error.php';
     133                        if ( is_readable( $php_error_pluggable ) ) {
     134                                require_once $php_error_pluggable;
     135
     136                                return;
     137                        }
     138                }
     139
     140                // Otherwise, display the default error template.
     141                $this->display_default_error_template();
     142        }
     143
     144        /**
     145         * Displays the default PHP error template.
     146         *
     147         * This method is called conditionally if no 'php-error.php' drop-in is available.
     148         *
     149         * It calls {@see wp_die()} with a message indicating that the site is experiencing technical difficulties and a
     150         * login link to the admin backend. The {@see 'wp_php_error_message'} and {@see 'wp_php_error_args'} filters can
     151         * be used to modify these parameters.
     152         *
     153         * @since 5.2.0
     154         */
     155        protected function display_default_error_template() {
     156                if ( ! function_exists( '__' ) ) {
     157                        wp_load_translations_early();
     158                }
     159
     160                if ( ! function_exists( 'wp_die' ) ) {
     161                        require_once ABSPATH . WPINC . '/functions.php';
     162                }
     163
     164                $message = __( 'The site is experiencing technical difficulties.' );
     165
     166                $args = array(
     167                        'response' => 500,
     168                        'exit'     => false,
     169                );
     170
     171                /**
     172                 * Filters the message that the default PHP error template displays.
     173                 *
     174                 * @since 5.2.0
     175                 *
     176                 * @param string $message HTML error message to display.
     177                 */
     178                $message = apply_filters( 'wp_php_error_message', $message );
     179
     180                /**
     181                 * Filters the arguments passed to {@see wp_die()} for the default PHP error template.
     182                 *
     183                 * @since 5.2.0
     184                 *
     185                 * @param array $args Associative array of arguments passed to `wp_die()`. By default these contain a
     186                 *                    'response' key, and optionally 'link_url' and 'link_text' keys.
     187                 */
     188                $args = apply_filters( 'wp_php_error_args', $args );
     189
     190                wp_die( $message, '', $args );
     191        }
     192}
  • new file src/wp-includes/class-wp-paused-extensions-storage.php

    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..00c2668b86
    - +  
     1<?php
     2/**
     3 * Error Protection API: WP_Paused_Extensions_Storage class
     4 *
     5 * @package WordPress
     6 * @since 5.2.0
     7 */
     8
     9/**
     10 * Core class used for storing paused extensions.
     11 *
     12 * @since 5.2.0
     13 */
     14class WP_Paused_Extensions_Storage {
     15
     16        /**
     17         * Option name for storing paused extensions.
     18         *
     19         * @since 5.2.0
     20         * @var string
     21         */
     22        protected $option_name;
     23
     24        /**
     25         * Constructor.
     26         *
     27         * @since 5.2.0
     28         */
     29        public function __construct() {
     30                $this->option_name = wp_recovery_mode()->get_recovery_mode_session_id() . '_paused_extensions';
     31        }
     32
     33        /**
     34         * Records an extension error.
     35         *
     36         * Only one error is stored per extension, with subsequent errors for the same extension overriding the
     37         * previously stored error.
     38         *
     39         * @since 5.2.0
     40         *
     41         * @param string $type      Extension type. Either 'plugin' or 'theme'.
     42         * @param string $extension Plugin or theme directory name.
     43         * @param array  $error     {
     44         *     Error that was triggered.
     45         *
     46         *     @type string $type    The error type.
     47         *     @type string $file    The name of the file in which the error occurred.
     48         *     @type string $line    The line number in which the error occurred.
     49         *     @type string $message The error message.
     50         * }
     51         * @return bool True on success, false on failure.
     52         */
     53        public function record( $type, $extension, $error ) {
     54                if ( ! $this->is_api_loaded() ) {
     55                        return false;
     56                }
     57
     58                if ( is_multisite() && is_site_meta_supported() ) {
     59                        $meta_key = $this->get_site_meta_key( $type, $extension );
     60
     61                        // Do not update if the error is already stored.
     62                        if ( get_site_meta( get_current_blog_id(), $meta_key, true ) === $error ) {
     63                                return true;
     64                        }
     65
     66                        return (bool) update_site_meta( get_current_blog_id(), $meta_key, $error );
     67                }
     68
     69                $paused_extensions = $this->get_all();
     70
     71                // Do not update if the error is already stored.
     72                if ( isset( $paused_extensions[ $type ][ $extension ] ) && $paused_extensions[ $type ][ $extension ] === $error ) {
     73                        return true;
     74                }
     75
     76                $paused_extensions[ $type ][ $extension ] = $error;
     77
     78                return update_option( $this->option_name, $paused_extensions );
     79        }
     80
     81        /**
     82         * Forgets a previously recorded extension error.
     83         *
     84         * @since 5.2.0
     85         *
     86         * @param string $type Extension type. Either 'plugin' or 'theme'.
     87         * @param string $extension Plugin or theme directory name.
     88         * @return bool True on success, false on failure.
     89         */
     90        public function forget( $type, $extension ) {
     91                if ( ! $this->is_api_loaded() ) {
     92                        return false;
     93                }
     94
     95                if ( is_multisite() && is_site_meta_supported() ) {
     96                        $meta_key = $this->get_site_meta_key( $type, $extension );
     97
     98                        // Do not delete if no error is stored.
     99                        if ( get_site_meta( get_current_blog_id(), $meta_key ) === array() ) {
     100                                return true;
     101                        }
     102
     103                        return delete_site_meta( get_current_blog_id(), $meta_key );
     104                }
     105
     106                $paused_extensions = $this->get_all();
     107
     108                // Do not delete if no error is stored.
     109                if ( ! isset( $paused_extensions[ $type ][ $extension ] ) ) {
     110                        return true;
     111                }
     112
     113                unset( $paused_extensions[ $type ][ $extension ] );
     114
     115                if ( empty( $paused_extensions[ $type ] ) ) {
     116                        unset( $paused_extensions[ $type ] );
     117                }
     118
     119                // Clean up the entire option if we're removing the only error.
     120                if ( ! $paused_extensions ) {
     121                        return delete_option( $this->option_name );
     122                }
     123
     124                return update_option( $this->option_name, $paused_extensions );
     125        }
     126
     127        /**
     128         * Gets the error for an extension, if paused.
     129         *
     130         * @since 5.2.0
     131         *
     132         * @param string $type Extension type. Either 'plugin' or 'theme'.
     133         * @param string $extension Plugin or theme directory name.
     134         * @return array|null Error that is stored, or null if the extension is not paused.
     135         */
     136        public function get( $type, $extension ) {
     137                if ( ! $this->is_api_loaded() ) {
     138                        return null;
     139                }
     140
     141                if ( is_multisite() && is_site_meta_supported() ) {
     142                        $error = get_site_meta( get_current_blog_id(), $this->get_site_meta_key( $type, $extension ), true );
     143                        if ( ! $error ) {
     144                                return null;
     145                        }
     146
     147                        return $error;
     148                }
     149
     150                $paused_extensions = $this->get_all( $type );
     151
     152                if ( ! isset( $paused_extensions[ $extension ] ) ) {
     153                        return null;
     154                }
     155
     156                return $paused_extensions[ $extension ];
     157        }
     158
     159        /**
     160         * Gets the paused extensions with their errors.
     161         *
     162         * @since 5.2.0
     163         *
     164         * @param string $type Optionally, limit to extensions of the given type.
     165         *
     166         * @return array Associative array of $type => array( $extension => $error ).
     167         *               If the extension type is provided, just the error entries are returned.
     168         */
     169        public function get_all( $type = '' ) {
     170                if ( ! $this->is_api_loaded() ) {
     171                        return array();
     172                }
     173
     174                if ( is_multisite() && is_site_meta_supported() ) {
     175                        $site_metadata = get_site_meta( get_current_blog_id() );
     176
     177                        $paused_extensions = array();
     178                        foreach ( $site_metadata as $meta_key => $meta_values ) {
     179                                if ( 0 !== strpos( $meta_key, $this->option_name . '_' ) ) {
     180                                        continue;
     181                                }
     182
     183                                $error = maybe_unserialize( array_shift( $meta_values ) );
     184
     185                                $without_prefix = substr( $meta_key, strlen( $this->option_name . '_' ) );
     186                                $parts          = explode( '_', $without_prefix, 2 );
     187
     188                                if ( ! isset( $parts[1] ) ) {
     189                                        continue;
     190                                }
     191
     192                                $paused_extensions[ $parts[0] ][ $parts[1] ] = $error;
     193                        }
     194                } else {
     195                        $paused_extensions = (array) get_option( $this->option_name, array() );
     196                }
     197
     198                if ( $type ) {
     199                        return isset( $paused_extensions[ $type ] ) ? $paused_extensions[ $type ] : array();
     200                }
     201
     202                return $paused_extensions;
     203        }
     204
     205        /**
     206         * Gets the site meta query clause for querying sites with paused extensions.
     207         *
     208         * @since 5.2.0
     209         *
     210         * @param string $type      Extension type. Either 'plugin' or 'theme'.
     211         * @param string $extension Plugin or theme directory name.
     212         * @return array A single clause to add to a meta query.
     213         */
     214        public function get_site_meta_query_clause( $type, $extension ) {
     215                return array(
     216                        'key'         => $this->get_site_meta_key( $type, $extension ),
     217                        'compare_key' => '=',
     218                );
     219        }
     220
     221        /**
     222         * Checks whether the underlying API to store paused extensions is loaded.
     223         *
     224         * @since 5.2.0
     225         *
     226         * @return bool True if the API is loaded, false otherwise.
     227         */
     228        protected function is_api_loaded() {
     229                if ( is_multisite() ) {
     230                        return function_exists( 'is_site_meta_supported' ) && function_exists( 'get_site_meta' );
     231                }
     232
     233                return function_exists( 'get_option' );
     234        }
     235
     236        /**
     237         * Get the site meta key for storing extension errors on Multisite.
     238         *
     239         * @since 5.2.0
     240         *
     241         * @param string $type
     242         * @param string $extension
     243         *
     244         * @return string
     245         */
     246        private function get_site_meta_key( $type, $extension ) {
     247                return $this->option_name . '_' . $type . '_' . $extension;
     248        }
     249}
  • new file src/wp-includes/class-wp-recovery-mode-controller.php

    diff --git a/src/wp-includes/class-wp-recovery-mode-controller.php b/src/wp-includes/class-wp-recovery-mode-controller.php
    new file mode 100644
    index 0000000000..c232a930ff
    - +  
     1<?php
     2
     3/**
     4 * Interface WP_Recovery_Mode_Controller
     5 */
     6interface WP_Recovery_Mode_Controller {
     7
     8        /**
     9         * Run the processor.
     10         *
     11         * This can be used for adding hooks, parsing the global request data,
     12         * exiting the request due to errors, etc..
     13         *
     14         * @since 5.2.0
     15         *
     16         * @return void
     17         */
     18        public function run();
     19
     20        /**
     21         * Handle a fatal error occurring when recovery mode is not yet active.
     22         *
     23         * @param array $error Error details {@see error_get_last()}
     24         *
     25         * @return void
     26         */
     27        public function handle_error( array $error );
     28
     29        /**
     30         * Is recovery mode active.
     31         *
     32         * @since 5.2.0
     33         *
     34         * @return bool
     35         */
     36        public function is_recovery_mode_active();
     37
     38        /**
     39         * Get the recovery mode session ID.
     40         *
     41         * @since 5.2.0
     42         *
     43         * @return string|false
     44         */
     45        public function get_recovery_mode_session_id();
     46}
  • new file src/wp-includes/class-wp-recovery-mode-cookie-service.php

    diff --git a/src/wp-includes/class-wp-recovery-mode-cookie-service.php b/src/wp-includes/class-wp-recovery-mode-cookie-service.php
    new file mode 100644
    index 0000000000..642c3705d2
    - +  
     1<?php
     2
     3final class WP_Recovery_Mode_Cookie_Service {
     4
     5        /** @var string */
     6        private $name;
     7
     8        /** @var string */
     9        private $domain;
     10
     11        /** @var string */
     12        private $path;
     13
     14        /** @var string */
     15        private $site_path;
     16
     17        /**
     18         * WP_Recovery_Mode_Cookie_Service constructor.
     19         *
     20         * @param array $opts
     21         */
     22        public function __construct( array $opts = array() ) {
     23                $opts = wp_parse_args( $opts, array(
     24                        'name'      => RECOVERY_MODE_COOKIE,
     25                        'domain'    => COOKIE_DOMAIN,
     26                        'path'      => COOKIEPATH,
     27                        'site_path' => SITECOOKIEPATH,
     28                ) );
     29
     30                $this->name      = $opts['name'];
     31                $this->domain    = $opts['domain'];
     32                $this->path      = $opts['path'];
     33                $this->site_path = $opts['site_path'];
     34        }
     35
     36        /**
     37         * Is the recovery mode cookie set.
     38         *
     39         * @since 5.2.0
     40         *
     41         * @return bool
     42         */
     43        public function is_cookie_set() {
     44                return ! empty( $_COOKIE[ $this->name ] );
     45        }
     46
     47        /**
     48         * Set the recovery mode cookie.
     49         *
     50         * This must be immediately followed by exiting the request.
     51         *
     52         * @since 5.2.0
     53         */
     54        public function set_cookie() {
     55
     56                $value = $this->generate_cookie();
     57
     58                setcookie( $this->name, $value, 0, $this->path, $this->domain, is_ssl(), true );
     59
     60                if ( $this->path !== $this->site_path ) {
     61                        setcookie( $this->name, $value, 0, $this->site_path, $this->domain, is_ssl(), true );
     62                }
     63        }
     64
     65        /**
     66         * Clear the recovery mode cookie.
     67         *
     68         * @sicne 5.2.0
     69         */
     70        public function clear_cookie() {
     71                setcookie( $this->name, ' ', time() - YEAR_IN_SECONDS, $this->path, $this->domain );
     72                setcookie( $this->name, ' ', time() - YEAR_IN_SECONDS, $this->site_path, $this->domain );
     73        }
     74
     75        /**
     76         * Validate the recovery mode cookie.
     77         *
     78         * @since 5.2.0
     79         *
     80         * @param string $cookie Optionally specify the cookie string.
     81         *                       If omitted, it will be retrieved from the super global.
     82         *
     83         * @return true|WP_Error
     84         */
     85        public function validate_cookie( $cookie = '' ) {
     86
     87                if ( ! $cookie ) {
     88                        if ( empty( $_COOKIE[ $this->name ] ) ) {
     89                                return new WP_Error( 'no_cookie', __( 'No cookie present.' ) );
     90                        }
     91
     92                        $cookie = $_COOKIE[ $this->name ];
     93                }
     94
     95                $parts = $this->parse_cookie( $cookie );
     96
     97                if ( is_wp_error( $parts ) ) {
     98                        return $parts;
     99                }
     100
     101                list( , $created_at, $random, $signature ) = $parts;
     102
     103                if ( ! ctype_digit( $created_at ) ) {
     104                        return new WP_Error( 'invalid_created_at', __( 'Invalid cookie format.' ) );
     105                }
     106
     107                /**
     108                 * Filter the length of time a Recovery Mode cookie is valid for.
     109                 *
     110                 * @since 5.2.0
     111                 *
     112                 * @param int $length Length in seconds.
     113                 */
     114                $length = apply_filters( 'recovery_mode_cookie_length', WEEK_IN_SECONDS );
     115
     116                if ( time() > $created_at + $length ) {
     117                        return new WP_Error( 'expired', __( 'Cookie expired.' ) );
     118                }
     119
     120                $to_sign = sprintf( 'recovery_mode|%s|%s', $created_at, $random );
     121                $hashed  = $this->recovery_mode_hash( $to_sign );
     122
     123                if ( ! hash_equals( $signature, $hashed ) ) {
     124                        return new WP_Error( 'signature_mismatch', __( 'Invalid cookie.' ) );
     125                }
     126
     127                return true;
     128        }
     129
     130        /**
     131         * Get the session identifier from the cookie.
     132         *
     133         * The cookie should be validated before calling this API.
     134         *
     135         * @since 5.2.0
     136         *
     137         * @param string $cookie Optionally specify the cookie string.
     138         *                       If omitted, it will be retrieved from the super global.
     139         *
     140         * @return string|WP_Error
     141         */
     142        public function get_session_id_from_cookie( $cookie = '' ) {
     143                if ( ! $cookie ) {
     144                        if ( empty( $_COOKIE[ $this->name ] ) ) {
     145                                return new WP_Error( 'no_cookie' );
     146                        }
     147
     148                        $cookie = $_COOKIE[ $this->name ];
     149                }
     150
     151                $parts = $this->parse_cookie( $cookie );
     152                if ( is_wp_error( $parts ) ) {
     153                        return $parts;
     154                }
     155
     156                list( , , $random ) = $parts;
     157
     158                return sha1( $random );
     159        }
     160
     161        /**
     162         * Parse the cookie into its four parts.
     163         *
     164         * @param string $cookie
     165         *
     166         * @return string[]|WP_Error
     167         */
     168        private function parse_cookie( $cookie ) {
     169                $cookie = base64_decode( $cookie );
     170                $parts  = explode( '|', $cookie );
     171
     172                if ( 4 !== count( $parts ) ) {
     173                        return new WP_Error( 'invalid_format', __( 'Invalid cookie format.' ) );
     174                }
     175
     176                return $parts;
     177        }
     178
     179        /**
     180         * Generate the recovery mode cookie value.
     181         *
     182         * The cookie is a base64 encoded string with the following format:
     183         *
     184         * recovery_mode|iat|rand|signature
     185         *
     186         * Where "recovery_mode" is a constant string,
     187         * iat is the time the cookie was generated at,
     188         * rand is a randomly generated password that is also used as a session identifier
     189         * and signature is an hmac of the preceding 3 parts.
     190         *
     191         * @since 5.2.0
     192         *
     193         * @return string
     194         */
     195        private function generate_cookie() {
     196
     197                if ( ! function_exists( 'wp_generate_password' ) ) {
     198                        require_once ABSPATH . WPINC . '/pluggable.php';
     199                }
     200
     201                $to_sign = sprintf( 'recovery_mode|%s|%s', time(), wp_generate_password( 20, false ) );
     202                $signed  = $this->recovery_mode_hash( $to_sign );
     203
     204                return base64_encode( sprintf( '%s|%s', $to_sign, $signed ) );
     205        }
     206
     207        /**
     208         * A form of `wp_hash()` specific to Recovery Mode.
     209         *
     210         * We cannot use `wp_hash()` because it is defined in `pluggable.php` which is not loaded until after plugins are loaded,
     211         * which is too late to verify the recovery mode cookie.
     212         *
     213         * This tries to use the `AUTH` salts first, but if they aren't valid specific salts will be generated and stored.
     214         *
     215         * @param string $data
     216         *
     217         * @return string|false
     218         */
     219        private function recovery_mode_hash( $data ) {
     220
     221                if ( ! defined( 'AUTH_KEY' ) || 'put your unique phrase here' === AUTH_KEY ) {
     222                        $auth_key = get_site_option( 'recovery_mode_auth_key' );
     223
     224                        if ( ! $auth_key ) {
     225                                if ( ! function_exists( 'wp_generate_password' ) ) {
     226                                        require_once ABSPATH . WPINC . '/pluggable.php';
     227                                }
     228
     229                                $auth_key = wp_generate_password( 64, true, true );
     230                                update_site_option( 'recovery_mode_auth_key', $auth_key );
     231                        }
     232                } else {
     233                        $auth_key = AUTH_KEY;
     234                }
     235
     236                if ( ! defined( 'AUTH_SALT' ) || 'put your unique phrase here' === AUTH_SALT || $auth_key === AUTH_SALT ) {
     237                        $auth_salt = get_site_option( 'recovery_mode_auth_salt' );
     238
     239                        if ( ! $auth_salt ) {
     240                                if ( ! function_exists( 'wp_generate_password' ) ) {
     241                                        require_once ABSPATH . WPINC . '/pluggable.php';
     242                                }
     243
     244                                $auth_salt = wp_generate_password( 64, true, true );
     245                                update_site_option( 'recovery_mode_auth_salt', $auth_salt );
     246                        }
     247                } else {
     248                        $auth_salt = AUTH_SALT;
     249                }
     250
     251                $secret = $auth_key . $auth_salt;
     252
     253                return hash_hmac( 'sha1', $data, $secret );
     254        }
     255}
  • new file src/wp-includes/class-wp-recovery-mode-email-controller.php

    diff --git a/src/wp-includes/class-wp-recovery-mode-email-controller.php b/src/wp-includes/class-wp-recovery-mode-email-controller.php
    new file mode 100644
    index 0000000000..2dbf92cc95
    - +  
     1<?php
     2
     3final class WP_Recovery_Mode_Email_Controller implements WP_Recovery_Mode_Controller {
     4
     5        const LOGIN_ACTION_ENTER = 'enter_recovery_mode';
     6        const LOGIN_ACTION_ENTERED = 'entered_recovery_mode';
     7
     8        /** @var WP_Recovery_Mode_Cookie_Service */
     9        private $cookies;
     10
     11        /** @var WP_Recovery_Mode_Key_Service */
     12        private $keys;
     13
     14        /** @var bool */
     15        private $is_active;
     16
     17        /** @var string|false */
     18        private $session_id = false;
     19
     20        /**
     21         * WP_Recovery_Mode_Email_Processor constructor.
     22         *
     23         * @param WP_Recovery_Mode_Cookie_Service $cookies
     24         * @param WP_Recovery_Mode_Key_Service    $keys
     25         */
     26        public function __construct( WP_Recovery_Mode_Cookie_Service $cookies, WP_Recovery_Mode_Key_Service $keys ) {
     27                $this->cookies = $cookies;
     28                $this->keys    = $keys;
     29        }
     30
     31        /**
     32         * @inheritdoc
     33         */
     34        public function is_recovery_mode_active() {
     35                return $this->is_active;
     36        }
     37
     38        /**
     39         * @inheritdoc
     40         */
     41        public function get_recovery_mode_session_id() {
     42                return $this->session_id;
     43        }
     44
     45        /**
     46         * @inheritdoc
     47         */
     48        public function run() {
     49                add_action( 'clear_auth_cookie', array( $this, 'on_clear_auth_cookie' ) );
     50
     51                if ( $this->cookies->is_cookie_set() ) {
     52                        $this->handle_cookie();
     53
     54                        return;
     55                }
     56
     57                if ( isset( $GLOBALS['pagenow'] ) && 'wp-login.php' === $GLOBALS['pagenow'] ) {
     58                        $this->handle_begin_link();
     59                }
     60        }
     61
     62        /**
     63         * When a fatal error occurs, send the recovery mode email.
     64         *
     65         * @since 5.2.0
     66         *
     67         * @param array $error Error details from {@see error_get_last()}
     68         */
     69        public function handle_error( array $error ) {
     70                if ( is_protected_endpoint() ) {
     71                        $this->maybe_send_recovery_mode_email( $error );
     72                }
     73        }
     74
     75        /**
     76         * Clear the recovery mode cookie when the auth cookies are cleared.
     77         *
     78         * @since 5.2.0
     79         */
     80        public function on_clear_auth_cookie() {
     81                /** This filter is documented in wp-includes/pluggable.php */
     82                if ( ! apply_filters( 'send_auth_cookies', true ) ) {
     83                        return;
     84                }
     85
     86                $this->cookies->clear_cookie();
     87        }
     88
     89        /**
     90         * Handle checking for the recovery mode cookie and validating it.
     91         *
     92         * @since 5.2.0
     93         */
     94        private function handle_cookie() {
     95                $validated = $this->cookies->validate_cookie();
     96
     97                if ( is_wp_error( $validated ) ) {
     98                        $this->cookies->clear_cookie();
     99
     100                        wp_die( $validated, '' );
     101                }
     102
     103                $this->is_active  = true;
     104                $this->session_id = $this->cookies->get_session_id_from_cookie();
     105        }
     106
     107        /**
     108         * Enter recovery mode when the user hits wp-login.php with a valid recovery mode link.
     109         *
     110         * @since 5.2.0
     111         */
     112        private function handle_begin_link() {
     113                if ( ! isset( $_GET['action'], $_GET['rm_key'] ) || self::LOGIN_ACTION_ENTER !== $_GET['action'] ) {
     114                        return;
     115                }
     116
     117                $validated = $this->keys->validate_recovery_mode_key( $_GET['rm_key'], $this->get_link_valid_for_interval() );
     118
     119                if ( is_wp_error( $validated ) ) {
     120                        wp_die( $validated, '' );
     121                }
     122
     123                $this->cookies->set_cookie();
     124
     125                // This should be loaded by set_recovery_mode_cookie() but load it again to be safe.
     126                if ( ! function_exists( 'wp_redirect' ) ) {
     127                        require_once ABSPATH . WPINC . '/pluggable.php';
     128                }
     129
     130                $url = add_query_arg( 'action', self::LOGIN_ACTION_ENTERED, wp_login_url() );
     131                wp_redirect( $url );
     132                die;
     133        }
     134
     135        /**
     136         * Get a URL to begin recovery mode.
     137         *
     138         * @since 5.2.0
     139         *
     140         * @param string $key Recovery Mode key created by {@see generate_and_store_recovery_mode_key()}
     141         *
     142         * @return string
     143         */
     144        private function get_recovery_mode_begin_url( $key ) {
     145
     146                $url = add_query_arg(
     147                        array(
     148                                'action' => self::LOGIN_ACTION_ENTER,
     149                                'rm_key' => $key,
     150                        ),
     151                        wp_login_url()
     152                );
     153
     154                /**
     155                 * Filter the URL to begin recovery mode.
     156                 *
     157                 * @since 5.2.0
     158                 *
     159                 * @param string $url
     160                 * @param string $key
     161                 */
     162                return apply_filters( 'recovery_mode_begin_url', $url, $key );
     163        }
     164
     165        /**
     166         * Get the interval the recovery mode email key is valid for.
     167         *
     168         * @since 5.2.0
     169         *
     170         * @return int Interval in seconds.
     171         */
     172        private function get_link_valid_for_interval() {
     173
     174                $rate_limit = $valid_for = $this->get_email_rate_limit();
     175
     176                /**
     177                 * Filter the amount of time the recovery mode email link is valid for.
     178                 *
     179                 * The interval time must be at least as long as the email rate limit.
     180                 *
     181                 * @since 5.2.0
     182                 *
     183                 * @param int $valid_for The number of seconds the link is valid for.
     184                 */
     185                $valid_for = apply_filters( 'recovery_mode_email_link_valid_for_interval', $valid_for );
     186
     187                return max( $valid_for, $rate_limit );
     188        }
     189
     190        /**
     191         * The rate limit between sending new recovery mode email links.
     192         *
     193         * @since 5.2.0
     194         *
     195         * @return int Rate limit in seconds.
     196         */
     197        private function get_email_rate_limit() {
     198                /**
     199                 * Filter the rate limit between sending new recovery mode email links.
     200                 *
     201                 * @since 5.2.0
     202                 *
     203                 * @param int $rate_limit Time to wait in seconds. Defaults to 4 hours.
     204                 */
     205                return apply_filters( 'recovery_mode_email_rate_limit', 4 * HOUR_IN_SECONDS );
     206        }
     207
     208        /**
     209         * Send the recovery mode email if the rate limit has not been sent.
     210         *
     211         * @since 5.2.0
     212         *
     213         * @param array $error Error details from {@see error_get_last()}
     214         *
     215         * @return true|WP_Error True if email sent, WP_Error otherwise.
     216         */
     217        private function maybe_send_recovery_mode_email( $error ) {
     218
     219                $rate_limit = $this->get_email_rate_limit();
     220
     221                $last_sent = get_site_option( 'recovery_mode_email_last_sent' );
     222
     223                if ( ! $last_sent || time() > $last_sent + $rate_limit ) {
     224                        $sent = $this->send_recovery_mode_email( $error );
     225                        update_site_option( 'recovery_mode_email_last_sent', time() );
     226
     227                        if ( $sent ) {
     228                                return true;
     229                        }
     230
     231                        return new WP_Error( 'email_failed', __( 'The email could not be sent. Possible reason: your host may have disabled the mail() function.' ) );
     232                }
     233
     234                $err_message = sprintf(
     235                /* translators: 1. Last sent as a human time diff 2. Wait time as a human time diff. */
     236                        __( 'A recovery link was already sent %1$s ago. Please wait another %2$s before requesting a new email.' ),
     237                        human_time_diff( $last_sent ),
     238                        human_time_diff( $last_sent + $rate_limit )
     239                );
     240
     241                return new WP_Error( 'email_sent_already', $err_message );
     242        }
     243
     244        /**
     245         * Send the Recovery Mode email to the site admin email address.
     246         *
     247         * @since 5.2.0
     248         *
     249         * @param array $error Error details from {@see error_get_last()}
     250         *
     251         * @return bool Whether the email was sent successfully.
     252         */
     253        private function send_recovery_mode_email( $error ) {
     254
     255                $key      = $this->keys->generate_and_store_recovery_mode_key();
     256                $url      = $this->get_recovery_mode_begin_url( $key );
     257                $blogname = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES );
     258
     259                $switched_locale = false;
     260
     261                // The switch_to_locale() function is loaded before it can actually be used.
     262                if ( function_exists( 'switch_to_locale' ) && isset( $GLOBALS['wp_locale_switcher'] ) ) {
     263                        $switched_locale = switch_to_locale( get_locale() );
     264                }
     265
     266                $extension = wp_get_extension_for_error( $error );
     267
     268                if ( $extension ) {
     269                        $cause   = $this->get_cause( $extension );
     270                        $details = $this->get_error_details( $error );
     271
     272                        if ( $details ) {
     273                                $header  = __( 'Error Details' );
     274                                $details = "\n\n" . $header . "\n" . str_pad( '', strlen( $header ), '=' ) . "\n" . $details;
     275                        }
     276                } else {
     277                        $cause = $details = '';
     278                }
     279
     280                $message = __(
     281                        'Howdy,
     282
     283Your site recently crashed on ###LOCATION### and may not be working as expected.
     284###CAUSE###
     285Click the link below to initiate recovery mode and fix the problem.
     286
     287This link expires in ###EXPIRES###.
     288
     289###LINK### ###DETAILS###
     290'
     291                );
     292                $message = str_replace(
     293                        array(
     294                                '###LINK###',
     295                                '###LOCATION###',
     296                                '###EXPIRES###',
     297                                '###CAUSE###',
     298                                '###DETAILS###',
     299                        ),
     300                        array(
     301                                $url,
     302                                'TBD',
     303                                human_time_diff( time() + $this->get_link_valid_for_interval() ),
     304                                $cause ? "\n{$cause}\n" : "\n",
     305                                $details,
     306                        ),
     307                        $message
     308                );
     309
     310                $email = array(
     311                        'to'      => $this->get_recovery_mode_email_address(),
     312                        'subject' => __( '[%s] Your Site Experienced an Issue' ),
     313                        'message' => $message,
     314                        'headers' => '',
     315                );
     316
     317                /**
     318                 * Filter the contents of the Recovery Mode email.
     319                 *
     320                 * @since 5.2.0
     321                 *
     322                 * @param array  $email Used to build wp_mail().
     323                 * @param string $key   Recovery mode key.
     324                 */
     325                $email = apply_filters( 'recovery_mode_email', $email, $key );
     326
     327                $sent = wp_mail(
     328                        $email['to'],
     329                        wp_specialchars_decode( sprintf( $email['subject'], $blogname ) ),
     330                        $email['message'],
     331                        $email['headers']
     332                );
     333
     334                if ( $switched_locale ) {
     335                        restore_previous_locale();
     336                }
     337
     338                return $sent;
     339        }
     340
     341        /**
     342         * Get the email address to send the recovery mode link to.
     343         *
     344         * @since 5.2.0
     345         *
     346         * @return string
     347         */
     348        private function get_recovery_mode_email_address() {
     349                if ( defined( 'RECOVERY_MODE_EMAIL' ) && is_email( RECOVERY_MODE_EMAIL ) ) {
     350                        return RECOVERY_MODE_EMAIL;
     351                }
     352
     353                return get_option( 'admin_email' );
     354        }
     355
     356        /**
     357         * Get a human readable description of the error.
     358         *
     359         * @since 5.2.0
     360         *
     361         * @param array $error Error details from {@see error_get_last()}
     362         *
     363         * @return string
     364         */
     365        private function get_error_details( $error ) {
     366                $constants   = get_defined_constants( true );
     367                $constants   = isset( $constants['Core'] ) ? $constants['Core'] : $constants['internal'];
     368                $core_errors = array();
     369
     370                foreach ( $constants as $constant => $value ) {
     371                        if ( 0 === strpos( $constant, 'E_' ) ) {
     372                                $core_errors[ $value ] = $constant;
     373                        }
     374                }
     375
     376                if ( isset( $core_errors[ $error['type'] ] ) ) {
     377                        $error['type'] = $core_errors[ $error['type'] ];
     378                }
     379
     380                /* translators: 1: error type, 2: error line number, 3: error file name, 4: error message */
     381                $error_message = __( "An error of type %1\$s in line %2\$s of the file %3\$s. \nError message: %4\$s" );
     382
     383                return sprintf(
     384                        $error_message,
     385                        $error['type'],
     386                        $error['line'],
     387                        $error['file'],
     388                        $error['message']
     389                );
     390        }
     391
     392        /**
     393         * Get the description indicating the possible cause for the error.
     394         *
     395         * @since 5.2.0
     396         *
     397         * @param array $extension The extension that caused the error.
     398         *
     399         * @return string
     400         */
     401        private function get_cause( $extension ) {
     402
     403                if ( 'plugin' === $extension['type'] ) {
     404                        if ( ! function_exists( 'get_plugins' ) ) {
     405                                require_once ABSPATH . 'wp-admin/includes/plugin.php';
     406                        }
     407
     408                        $names = array();
     409
     410                        foreach ( get_plugins() as $file => $plugin ) {
     411                                if ( 0 === strpos( $file, "{$extension['slug']}/" ) ) {
     412                                        $names[] = $plugin['Name'];
     413                                }
     414                        }
     415
     416                        if ( ! $names ) {
     417                                $names[] = $extension['slug'];
     418                        }
     419
     420                        // Multiple plugins can technically be in the same directory.
     421                        $cause = wp_sprintf( _n( 'This may be caused by the %l plugin.', 'This may be caused by the %l plugins.', count( $names ) ), $names );
     422                } else {
     423                        $theme = wp_get_theme( $extension['slug'] );
     424                        $name  = $theme->exists() ? $theme->display( 'Name' ) : $extension['slug'];
     425
     426                        $cause = sprintf( __( 'This may be caused by the %s theme.' ), $name );
     427                }
     428
     429                return $cause;
     430        }
     431}
  • new file src/wp-includes/class-wp-recovery-mode-key-service.php

    diff --git a/src/wp-includes/class-wp-recovery-mode-key-service.php b/src/wp-includes/class-wp-recovery-mode-key-service.php
    new file mode 100644
    index 0000000000..2e169ba9c2
    - +  
     1<?php
     2
     3final class WP_Recovery_Mode_Key_Service {
     4
     5        /**
     6         * Create a recovery mode key.
     7         *
     8         * @since 5.2.0
     9         *
     10         * @global PasswordHash $wp_hasher
     11         *
     12         * @return string Recovery mode key.
     13         */
     14        public function generate_and_store_recovery_mode_key() {
     15
     16                global $wp_hasher;
     17
     18                if ( ! function_exists( 'wp_generate_password' ) ) {
     19                        require_once ABSPATH . WPINC . '/pluggable.php';
     20                }
     21
     22                $key = wp_generate_password( 20, false );
     23
     24                /**
     25                 * Fires when a recovery mode key is generated for a user.
     26                 *
     27                 * @since 5.2.0
     28                 *
     29                 * @param string $key The recovery mode key.
     30                 */
     31                do_action( 'generate_recovery_mode_key', $key );
     32
     33                if ( empty( $wp_hasher ) ) {
     34                        require_once ABSPATH . WPINC . '/class-phpass.php';
     35                        $wp_hasher = new PasswordHash( 8, true );
     36                }
     37
     38                $hashed = $wp_hasher->HashPassword( $key );
     39
     40                update_site_option( 'recovery_key', array(
     41                        'hashed_key' => $hashed,
     42                        'created_at' => time(),
     43                ) );
     44
     45                return $key;
     46        }
     47
     48        /**
     49         * Verify if the recovery mode key is correct.
     50         *
     51         * @since 5.2.0
     52         *
     53         * @param string $key The unhashed key.
     54         * @param int    $ttl Time in seconds for the key to be valid for.
     55         *
     56         * @return true|WP_Error
     57         */
     58        public function validate_recovery_mode_key( $key, $ttl ) {
     59
     60                $record = get_site_option( 'recovery_key' );
     61
     62                if ( ! $record ) {
     63                        return new WP_Error( 'no_recovery_key_set', __( 'Recovery Mode not initialized.' ) );
     64                }
     65
     66                if ( ! is_array( $record ) || ! isset( $record['hashed_key'], $record['created_at'] ) ) {
     67                        return new WP_Error( 'invalid_recovery_key_format', __( 'Invalid recovery key format.' ) );
     68                }
     69
     70                if ( ! function_exists( 'wp_check_password' ) ) {
     71                        require_once ABSPATH . WPINC . '/pluggable.php';
     72                }
     73
     74                if ( ! wp_check_password( $key, $record['hashed_key'] ) ) {
     75                        return new WP_Error( 'hash_mismatch', __( 'Invalid recovery key.' ) );
     76                }
     77
     78                if ( time() > $record['created_at'] + $ttl ) {
     79                        return new WP_Error( 'key_expired', __( 'Recovery key expired.' ) );
     80                }
     81
     82                return true;
     83        }
     84}
  • src/wp-includes/class-wp-theme.php

    diff --git a/src/wp-includes/class-wp-theme.php b/src/wp-includes/class-wp-theme.php
    index 22a5768448..f190eeb968 100644
    a b final class WP_Theme implements ArrayAccess { 
    371371                        $this->parent = new WP_Theme( $this->template, isset( $theme_root_template ) ? $theme_root_template : $this->theme_root, $this );
    372372                }
    373373
     374                if ( wp_paused_extensions()->get( 'theme', $this->stylesheet ) && ( ! is_wp_error( $this->errors ) || ! isset( $this->errors->errors['theme_paused'] ) ) ) {
     375                        $this->errors = new WP_Error( 'theme_paused', __( 'This theme failed to load properly and was paused within the admin backend.' ) );
     376                }
     377
    374378                // We're good. If we didn't retrieve from cache, set it.
    375379                if ( ! is_array( $cache ) ) {
    376380                        $cache = array(
  • src/wp-includes/default-constants.php

    diff --git a/src/wp-includes/default-constants.php b/src/wp-includes/default-constants.php
    index 1d3fd5df98..b1830ac76d 100644
    a b function wp_cookie_constants() { 
    302302        if ( ! defined( 'COOKIE_DOMAIN' ) ) {
    303303                define( 'COOKIE_DOMAIN', false );
    304304        }
     305
     306        if ( ! defined( 'RECOVERY_MODE_COOKIE' ) ) {
     307                /**
     308                 * @since 5.2.0
     309                 */
     310                define( 'RECOVERY_MODE_COOKIE', 'wordpress_rec_' . COOKIEHASH );
     311        }
    305312}
    306313
    307314/**
  • src/wp-includes/default-filters.php

    diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php
    index 1319826212..7242a11282 100644
    a b add_filter( 'pre_oembed_result', 'wp_filter_pre_oembed_result', 10, 3 ); 
    578578
    579579// Capabilities
    580580add_filter( 'user_has_cap', 'wp_maybe_grant_install_languages_cap', 1 );
     581add_filter( 'user_has_cap', 'wp_maybe_grant_resume_extensions_caps', 1 );
    581582
    582583unset( $filter, $action );
  • new file src/wp-includes/error-protection.php

    diff --git a/src/wp-includes/error-protection.php b/src/wp-includes/error-protection.php
    new file mode 100644
    index 0000000000..b1b961bb27
    - +  
     1<?php
     2/**
     3 * Error Protection API: Functions
     4 *
     5 * @package WordPress
     6 * @since   5.2.0
     7 */
     8
     9/**
     10 * Get the instance for storing paused extensions.
     11 *
     12 * @return WP_Paused_Extensions_Storage
     13 */
     14function wp_paused_extensions() {
     15        static $wp_paused_extensions_storage = null;
     16
     17        if ( null === $wp_paused_extensions_storage ) {
     18                $wp_paused_extensions_storage = new WP_Paused_Extensions_Storage();
     19        }
     20
     21        return $wp_paused_extensions_storage;
     22}
     23
     24/**
     25 * Records the extension error as a database option.
     26 *
     27 * @since 5.2.0
     28 *
     29 * @param array $error Error that was triggered.
     30 *
     31 * @return bool Whether the error was correctly recorded.
     32 */
     33function wp_record_extension_error( $error ) {
     34
     35        $extension = wp_get_extension_for_error( $error );
     36
     37        if ( ! $extension ) {
     38                return false;
     39        }
     40
     41        return wp_paused_extensions()->record( $extension['type'], $extension['slug'], $error );
     42}
     43
     44/**
     45 * Get the extension that the error occurred in.
     46 *
     47 * @since 5.2.0
     48 *
     49 * @global array $wp_theme_directories
     50 *
     51 * @param array  $error Error that was triggered.
     52 *
     53 * @return array|false array( 'slug' => (string), 'type' => 'plugin' | 'theme' )
     54 *                     Slug is the plugin or theme directory as opposed to the full file.
     55 *                     Or false on error.
     56 */
     57function wp_get_extension_for_error( $error ) {
     58        global $wp_theme_directories;
     59
     60        if ( ! isset( $error['file'] ) ) {
     61                return false;
     62        }
     63
     64        if ( ! defined( 'WP_PLUGIN_DIR' ) ) {
     65                return false;
     66        }
     67
     68        $error_file    = wp_normalize_path( $error['file'] );
     69        $wp_plugin_dir = wp_normalize_path( WP_PLUGIN_DIR );
     70
     71        if ( 0 === strpos( $error_file, $wp_plugin_dir ) ) {
     72                $path  = str_replace( $wp_plugin_dir . '/', '', $error_file );
     73                $parts = explode( '/', $path );
     74
     75                return array( 'type' => 'plugin', 'slug' => $parts[0] );
     76        }
     77
     78        if ( empty( $wp_theme_directories ) ) {
     79                return false;
     80        }
     81
     82        foreach ( $wp_theme_directories as $theme_directory ) {
     83                $theme_directory = wp_normalize_path( $theme_directory );
     84
     85                if ( 0 === strpos( $error_file, $theme_directory ) ) {
     86                        $path  = str_replace( $theme_directory . '/', '', $error_file );
     87                        $parts = explode( '/', $path );
     88
     89                        return array( 'type' => 'theme', 'slug' => $parts[0] );
     90                }
     91        }
     92
     93        return false;
     94}
     95
     96/**
     97 * Forgets a previously recorded extension error again.
     98 *
     99 * @since 5.2.0
     100 *
     101 * @param string $type         Type of the extension.
     102 * @param string $extension    Relative path of the extension.
     103 * @param bool   $network_wide Optional. Whether to resume the plugin for the entire
     104 *                             network. Default false.
     105 *
     106 * @return bool Whether the extension error was successfully forgotten.
     107 */
     108function wp_forget_extension_error( $type, $extension, $network_wide = false ) {
     109
     110        list( $extension ) = explode( '/', $extension );
     111
     112        if ( empty( $extension ) ) {
     113                return false;
     114        }
     115
     116        $storage = wp_paused_extensions();
     117
     118        // Handle manually since the regular APIs do not expose this functionality.
     119        if ( $network_wide && is_site_meta_supported() ) {
     120                $site_meta_query_clause = $storage->get_site_meta_query_clause( $type, $extension );
     121                return delete_metadata( 'blog', 0, $site_meta_query_clause['key'], '', true );
     122        }
     123
     124        return $storage->forget( $type, $extension );
     125}
     126
     127/**
     128 * Determines whether we are dealing with an error that WordPress should handle
     129 * in order to protect the admin backend against WSODs.
     130 *
     131 * @param array $error Error information retrieved from error_get_last().
     132 *
     133 * @return bool Whether WordPress should handle this error.
     134 */
     135function wp_should_handle_error( $error ) {
     136        if ( ! isset( $error['type'] ) ) {
     137                return false;
     138        }
     139
     140        $error_types_to_handle = array(
     141                E_ERROR,
     142                E_PARSE,
     143                E_USER_ERROR,
     144                E_COMPILE_ERROR,
     145                E_RECOVERABLE_ERROR,
     146        );
     147
     148        return in_array( $error['type'], $error_types_to_handle, true );
     149}
     150
     151/**
     152 * Registers the shutdown handler for fatal errors.
     153 *
     154 * The handler will only be registered if {@see wp_is_fatal_error_handler_enabled()} returns true.
     155 *
     156 * @since 5.2.0
     157 */
     158function wp_register_fatal_error_handler() {
     159        if ( ! wp_is_fatal_error_handler_enabled() ) {
     160                return;
     161        }
     162
     163        $handler = null;
     164        if ( defined( 'WP_CONTENT_DIR' ) && is_readable( WP_CONTENT_DIR . '/fatal-error-handler.php' ) ) {
     165                $handler = include WP_CONTENT_DIR . '/fatal-error-handler.php';
     166        }
     167
     168        if ( ! is_object( $handler ) || ! is_callable( array( $handler, 'handle' ) ) ) {
     169                $handler = new WP_Fatal_Error_Handler();
     170        }
     171
     172        register_shutdown_function( array( $handler, 'handle' ) );
     173}
     174
     175/**
     176 * Checks whether the fatal error handler is enabled.
     177 *
     178 * A constant `WP_DISABLE_FATAL_ERROR_HANDLER` can be set in `wp-config.php` to disable it, or alternatively the
     179 * {@see 'wp_fatal_error_handler_enabled'} filter can be used to modify the return value.
     180 *
     181 * @since 5.2.0
     182 *
     183 * @return bool True if the fatal error handler is enabled, false otherwise.
     184 */
     185function wp_is_fatal_error_handler_enabled() {
     186        $enabled = ! defined( 'WP_DISABLE_FATAL_ERROR_HANDLER' ) || ! WP_DISABLE_FATAL_ERROR_HANDLER;
     187
     188        /**
     189         * Filters whether the fatal error handler is enabled.
     190         *
     191         * @since 5.2.0
     192         *
     193         * @param bool $enabled True if the fatal error handler is enabled, false otherwise.
     194         */
     195        return apply_filters( 'wp_fatal_error_handler_enabled', $enabled );
     196}
     197
     198/**
     199 * Access the WordPress Recovery Mode controller.
     200 *
     201 * @since 5.2.0
     202 *
     203 * @return WP_Recovery_Mode_Controller
     204 */
     205function wp_recovery_mode() {
     206        static $wp_recovery_mode;
     207
     208        if ( ! $wp_recovery_mode ) {
     209                $default = new WP_Recovery_Mode_Email_Controller(
     210                        new WP_Recovery_Mode_Cookie_Service(),
     211                        new WP_Recovery_Mode_Key_Service()
     212                );
     213
     214                if ( defined( 'WP_CONTENT_DIR' ) && is_readable( WP_CONTENT_DIR . '/recovery-mode-controller.php' ) ) {
     215                        $wp_recovery_mode = include WP_CONTENT_DIR . '/recovery-mode-controller.php';
     216                }
     217
     218                if ( ! $wp_recovery_mode instanceof WP_Recovery_Mode_Controller ) {
     219                        $wp_recovery_mode = $default;
     220                }
     221
     222                /**
     223                 * Filter the recovery mode controller.
     224                 *
     225                 * This filter can only be used by mu-plugins.
     226                 *
     227                 * @since 5.2.0
     228                 *
     229                 * @param WP_Recovery_Mode_Controller $wp_recovery_mode
     230                 */
     231                $wp_recovery_mode = apply_filters( 'wp_recovery_mode_controller', $wp_recovery_mode );
     232
     233                if ( ! $wp_recovery_mode instanceof WP_Recovery_Mode_Controller ) {
     234                        $wp_recovery_mode = $default;
     235                }
     236        }
     237
     238        return $wp_recovery_mode;
     239}
  • src/wp-includes/load.php

    diff --git a/src/wp-includes/load.php b/src/wp-includes/load.php
    index 245ecb1e61..1b0af818e3 100644
    a b function wp_get_active_and_valid_plugins() { 
    697697                }
    698698        }
    699699
     700        /*
     701         * Remove plugins from the list of active plugins when we're on an endpoint
     702         * that should be protected against WSODs and the plugin is paused.
     703         */
     704        if ( wp_is_recovery_mode() ) {
     705                $plugins = wp_skip_paused_plugins( $plugins );
     706        }
     707
     708        return $plugins;
     709}
     710
     711/**
     712 * Filters a given list of plugins, removing any paused plugins from it.
     713 *
     714 * @since 5.2.0
     715 *
     716 * @param array $plugins List of absolute plugin main file paths.
     717 * @return array Filtered value of $plugins, without any paused plugins.
     718 */
     719function wp_skip_paused_plugins( array $plugins ) {
     720        $paused_plugins = wp_paused_extensions()->get_all( 'plugin' );
     721
     722        if ( empty( $paused_plugins ) ) {
     723                return $plugins;
     724        }
     725
     726        foreach ( $plugins as $index => $plugin ) {
     727                list( $plugin ) = explode( '/', plugin_basename( $plugin ) );
     728
     729                if ( array_key_exists( $plugin, $paused_plugins ) ) {
     730                        unset( $plugins[ $index ] );
     731
     732                        // Store list of paused plugins for displaying an admin notice.
     733                        $GLOBALS['_paused_plugins'][ $plugin ] = $paused_plugins[ $plugin ];
     734                }
     735        }
     736
    700737        return $plugins;
    701738}
    702739
    function wp_get_active_and_valid_themes() { 
    725762
    726763        $themes[] = TEMPLATEPATH;
    727764
     765        /*
     766         * Remove themes from the list of active themes when we're on an endpoint
     767         * that should be protected against WSODs and the theme is paused.
     768         */
     769        if ( wp_is_recovery_mode() ) {
     770                $themes = wp_skip_paused_themes( $themes );
     771
     772                // If no active and valid themes exist, skip loading themes.
     773                if ( empty( $themes ) ) {
     774                        add_filter( 'wp_using_themes', '__return_false' );
     775                }
     776        }
     777
     778        return $themes;
     779}
     780
     781/**
     782 * Filters a given list of themes, removing any paused themes from it.
     783 *
     784 * @since 5.2.0
     785 *
     786 * @param array $themes List of absolute theme directory paths.
     787 * @return array Filtered value of $themes, without any paused themes.
     788 */
     789function wp_skip_paused_themes( array $themes ) {
     790        $paused_themes = wp_paused_extensions()->get_all( 'theme' );
     791
     792        if ( empty( $paused_themes ) ) {
     793                return $themes;
     794        }
     795
     796        foreach ( $themes as $index => $theme ) {
     797                $theme = basename( $theme );
     798
     799                if ( array_key_exists( $theme, $paused_themes ) ) {
     800                        unset( $themes[ $index ] );
     801
     802                        // Store list of paused themes for displaying an admin notice.
     803                        $GLOBALS['_paused_themes'][ $theme ] = $paused_themes[ $theme ];
     804                }
     805        }
     806
    728807        return $themes;
    729808}
    730809
     810/**
     811 * Is WordPress in Recovery Mode.
     812 *
     813 * In this mode, plugins or themes that cause WSODs will be paused.
     814 *
     815 * @since 5.2.0
     816 *
     817 * @return bool
     818 */
     819function wp_is_recovery_mode() {
     820        return wp_recovery_mode()->is_recovery_mode_active();
     821}
     822
     823/**
     824 * Determines whether we are currently on an endpoint that should be protected against WSODs.
     825 *
     826 * @since 5.2.0
     827 *
     828 * @return bool True if the current endpoint should be protected.
     829 */
     830function is_protected_endpoint() {
     831        // Protect login pages.
     832        if ( isset( $GLOBALS['pagenow'] ) && 'wp-login.php' === $GLOBALS['pagenow'] ) {
     833                return true;
     834        }
     835
     836        // Protect the admin backend.
     837        if ( is_admin() && ! wp_doing_ajax() ) {
     838                return true;
     839        }
     840
     841        // Protect AJAX actions that could help resolve a fatal error should be available.
     842        if ( is_protected_ajax_action() ) {
     843                return true;
     844        }
     845
     846        /**
     847         * Filters whether the current request is against a protected endpoint.
     848         *
     849         * This filter is only fired when an endpoint is requested which is not already protected by
     850         * WordPress core. As such, it exclusively allows providing further protected endpoints in
     851         * addition to the admin backend, login pages and protected AJAX actions.
     852         *
     853         * @since 5.2.0
     854         *
     855         * @param bool $is_protected_endpoint Whether the currently requested endpoint is protected. Default false.
     856         */
     857        return (bool) apply_filters( 'is_protected_endpoint', false );
     858}
     859
     860/**
     861 * Determines whether we are currently handling an AJAX action that should be protected against WSODs.
     862 *
     863 * @since 5.2.0
     864 *
     865 * @return bool True if the current AJAX action should be protected.
     866 */
     867function is_protected_ajax_action() {
     868        if ( ! wp_doing_ajax() ) {
     869                return false;
     870        }
     871
     872        if ( ! isset( $_REQUEST['action'] ) ) {
     873                return false;
     874        }
     875
     876        $actions_to_protect = array(
     877                'edit-theme-plugin-file', // Saving changes in the core code editor.
     878                'heartbeat',              // Keep the heart beating.
     879                'install-plugin',         // Installing a new plugin.
     880                'install-theme',          // Installing a new theme.
     881                'search-plugins',         // Searching in the list of plugins.
     882                'search-install-plugins', // Searching for a plugin in the plugin install screen.
     883                'update-plugin',          // Update an existing plugin.
     884                'update-theme',           // Update an existing theme.
     885        );
     886
     887        /**
     888         * Filters the array of protected AJAX actions.
     889         *
     890         * This filter is only fired when doing AJAX and the AJAX request has an 'action' property.
     891         *
     892         * @since 5.2.0
     893         *
     894         * @param array $actions_to_protect Array of strings with AJAX actions to protect.
     895         */
     896        $actions_to_protect = (array) apply_filters( 'wp_protected_ajax_actions', $actions_to_protect );
     897
     898        if ( ! in_array( $_REQUEST['action'], $actions_to_protect, true ) ) {
     899                return false;
     900        }
     901
     902        return true;
     903}
     904
    731905/**
    732906 * Set internal encoding.
    733907 *
  • src/wp-login.php

    diff --git a/src/wp-login.php b/src/wp-login.php
    index b02a2b9e70..c162c610cb 100644
    a b if ( isset( $_GET['key'] ) ) { 
    439439}
    440440
    441441// Validate action so as to default to the login screen.
    442 if ( ! in_array( $action, array( 'postpass', 'logout', 'lostpassword', 'retrievepassword', 'resetpass', 'rp', 'register', 'login', 'confirmaction' ), true ) && false === has_filter( 'login_form_' . $action ) ) {
     442if ( ! in_array( $action, array( 'postpass', 'logout', 'lostpassword', 'retrievepassword', 'resetpass', 'rp', 'register', 'login', 'confirmaction', WP_Recovery_Mode_Email_Controller::LOGIN_ACTION_ENTERED ), true ) && false === has_filter( 'login_form_' . $action ) ) {
    443443        $action = 'login';
    444444}
    445445
    switch ( $action ) { 
    10291029                                $errors->add( 'registered', __( 'Registration complete. Please check your email.' ), 'message' );
    10301030                        } elseif ( strpos( $redirect_to, 'about.php?updated' ) ) {
    10311031                                $errors->add( 'updated', __( '<strong>You have successfully updated WordPress!</strong> Please log back in to see what&#8217;s new.' ), 'message' );
     1032                        } elseif ( WP_Recovery_Mode_Email_Controller::LOGIN_ACTION_ENTERED === $action ) {
     1033                                $errors->add( 'enter_recovery_mode', __( 'Recovery Mode Initialized. Please login to continue.' ), 'message' );
    10321034                        }
    10331035                }
    10341036
  • src/wp-settings.php

    diff --git a/src/wp-settings.php b/src/wp-settings.php
    index a520ff96c8..393aa7de0d 100644
    a b define( 'WPINC', 'wp-includes' ); 
    1717
    1818// Include files required for initialization.
    1919require( ABSPATH . WPINC . '/load.php' );
     20require( ABSPATH . WPINC . '/class-wp-paused-extensions-storage.php' );
     21require( ABSPATH . WPINC . '/class-wp-fatal-error-handler.php' );
     22require( ABSPATH . WPINC . '/error-protection.php' );
    2023require( ABSPATH . WPINC . '/default-constants.php' );
    2124require_once( ABSPATH . WPINC . '/plugin.php' );
    2225
     26// Make sure we register the shutdown handler for fatal errors as soon as possible.
     27wp_register_fatal_error_handler();
     28
    2329/*
    2430 * These can't be directly globalized in version.php. When updating,
    2531 * we're including version.php from another installation and don't want
    require( ABSPATH . WPINC . '/blocks/rss.php' ); 
    261267require( ABSPATH . WPINC . '/blocks/search.php' );
    262268require( ABSPATH . WPINC . '/blocks/shortcode.php' );
    263269require( ABSPATH . WPINC . '/blocks/tag-cloud.php' );
     270require( ABSPATH . WPINC . '/class-wp-recovery-mode-cookie-service.php' );
     271require( ABSPATH . WPINC . '/class-wp-recovery-mode-key-service.php' );
     272require( ABSPATH . WPINC . '/class-wp-recovery-mode-controller.php' );
     273require( ABSPATH . WPINC . '/class-wp-recovery-mode-email-controller.php' );
    264274
    265275$GLOBALS['wp_embed'] = new WP_Embed();
    266276
    wp_start_scraping_edited_file_errors(); 
    340350// Register the default theme directory root
    341351register_theme_directory( get_theme_root() );
    342352
     353// Handle users requesting a recovery mode link and initiating recovery mode.
     354wp_recovery_mode()->run();
     355
    343356// Load active plugins.
    344357foreach ( wp_get_active_and_valid_plugins() as $plugin ) {
    345358        wp_register_plugin_realpath( $plugin );
    if ( is_multisite() ) { 
    528541 * @since 3.0.0
    529542 */
    530543do_action( 'wp_loaded' );
     544
     545/*
     546 * Store the fact that we could successfully execute the entire WordPress
     547 * lifecycle. This is used to skip the premature shutdown handler, as it cannot
     548 * be unregistered.
     549 */
     550if ( ! defined( 'WP_EXECUTION_SUCCEEDED' ) ) {
     551        define( 'WP_EXECUTION_SUCCEEDED', true );
     552}
  • new file tests/phpunit/tests/recovery-mode.php

    diff --git a/tests/phpunit/tests/recovery-mode.php b/tests/phpunit/tests/recovery-mode.php
    new file mode 100644
    index 0000000000..777430a377
    - +  
     1<?php
     2
     3class Tests_Recovery_Mode extends WP_UnitTestCase {
     4
     5        private static $subscriber;
     6        private static $administrator;
     7
     8        public static function setUpBeforeClass() {
     9                self::$subscriber    = self::factory()->user->create( array( 'role' => 'subscriber' ) );
     10                self::$administrator = self::factory()->user->create( array( 'role' => 'administrator' ) );
     11
     12                return parent::setUpBeforeClass();
     13        }
     14
     15        public static function tearDownAfterClass() {
     16                wp_delete_user( self::$subscriber );
     17                wp_delete_user( self::$administrator );
     18
     19                return parent::tearDownAfterClass();
     20        }
     21
     22        public function test_generate_and_store_returns_recovery_key() {
     23                $service = new WP_Recovery_Mode_Key_Service();
     24                $key     = $service->generate_and_store_recovery_mode_key();
     25
     26                $this->assertNotWPError( $key );
     27        }
     28
     29        public function test_verify_recovery_mode_key_returns_wp_error_if_no_key_set() {
     30                $service = new WP_Recovery_Mode_Key_Service();
     31                $error   = $service->validate_recovery_mode_key( 'abcd', HOUR_IN_SECONDS );
     32
     33                $this->assertWPError( $error );
     34                $this->assertEquals( 'no_recovery_key_set', $error->get_error_code() );
     35        }
     36
     37        public function test_verify_recovery_mode_key_returns_wp_error_if_stored_format_is_invalid() {
     38                update_site_option( 'recovery_key', 'gibberish' );
     39
     40                $service = new WP_Recovery_Mode_Key_Service();
     41                $error   = $service->validate_recovery_mode_key( 'abcd', HOUR_IN_SECONDS );
     42
     43                $this->assertWPError( $error );
     44                $this->assertEquals( 'invalid_recovery_key_format', $error->get_error_code() );
     45        }
     46
     47        public function test_verify_recovery_mode_key_returns_wp_error_if_empty_key() {
     48                $service = new WP_Recovery_Mode_Key_Service();
     49                $service->generate_and_store_recovery_mode_key();
     50                $error = $service->validate_recovery_mode_key( '', HOUR_IN_SECONDS );
     51
     52                $this->assertWPError( $error );
     53                $this->assertEquals( 'hash_mismatch', $error->get_error_code() );
     54        }
     55
     56        public function test_verify_recovery_mode_key_returns_wp_error_if_hash_mismatch() {
     57                $service = new WP_Recovery_Mode_Key_Service();
     58                $service->generate_and_store_recovery_mode_key();
     59                $error = $service->validate_recovery_mode_key( 'abcd', HOUR_IN_SECONDS );
     60
     61                $this->assertWPError( $error );
     62                $this->assertEquals( 'hash_mismatch', $error->get_error_code() );
     63        }
     64
     65        public function test_verify_recovery_mode_key_returns_wp_error_if_expired() {
     66                $service = new WP_Recovery_Mode_Key_Service();
     67                $key     = $service->generate_and_store_recovery_mode_key();
     68
     69                $record               = get_site_option( 'recovery_key' );
     70                $record['created_at'] = time() - HOUR_IN_SECONDS - 30;
     71                update_site_option( 'recovery_key', $record );
     72
     73                $error = $service->validate_recovery_mode_key( $key, HOUR_IN_SECONDS );
     74
     75                $this->assertWPError( $error );
     76                $this->assertEquals( 'key_expired', $error->get_error_code() );
     77        }
     78
     79        public function test_verify_recovery_mode_key_returns_true_for_valid_key() {
     80                $service = new WP_Recovery_Mode_Key_Service();
     81                $key     = $service->generate_and_store_recovery_mode_key();
     82                $this->assertTrue( $service->validate_recovery_mode_key( $key, HOUR_IN_SECONDS ) );
     83        }
     84
     85        public function test_validate_recovery_mode_cookie_returns_wp_error_if_invalid_format() {
     86
     87                $service = new WP_Recovery_Mode_Cookie_Service();
     88
     89                $error = $service->validate_cookie( 'gibbersih' );
     90                $this->assertWPError( $error );
     91                $this->assertEquals( 'invalid_format', $error->get_error_code() );
     92
     93                $error = $service->validate_cookie( base64_encode( 'test|data|format' ) );
     94                $this->assertWPError( $error );
     95                $this->assertEquals( 'invalid_format', $error->get_error_code() );
     96
     97                $error = $service->validate_cookie( base64_encode( 'test|data|format|to|long' ) );
     98                $this->assertWPError( $error );
     99                $this->assertEquals( 'invalid_format', $error->get_error_code() );
     100        }
     101
     102        public function test_validate_recovery_mode_cookie_returns_wp_error_if_expired() {
     103                $service    = new WP_Recovery_Mode_Cookie_Service();
     104                $reflection = new ReflectionMethod( $service, 'recovery_mode_hash' );
     105                $reflection->setAccessible( true );
     106
     107                $to_sign = sprintf( 'recovery_mode|%s|%s', time() - WEEK_IN_SECONDS - 30, wp_generate_password( 20, false ) );
     108                $signed  = $reflection->invoke( $service, $to_sign );
     109                $cookie  = base64_encode( sprintf( '%s|%s', $to_sign, $signed ) );
     110
     111                $error = $service->validate_cookie( $cookie );
     112                $this->assertWPError( $error );
     113                $this->assertEquals( 'expired', $error->get_error_code() );
     114        }
     115
     116        public function test_validate_recovery_mode_cookie_returns_wp_error_if_signature_mismatch() {
     117                $service    = new WP_Recovery_Mode_Cookie_Service();
     118                $reflection = new ReflectionMethod( $service, 'generate_cookie' );
     119                $reflection->setAccessible( true );
     120
     121                $cookie = $reflection->invoke( $service );
     122                $cookie .= 'gibbersih';
     123
     124                $error = $service->validate_cookie( $cookie );
     125                $this->assertWPError( $error );
     126                $this->assertEquals( 'signature_mismatch', $error->get_error_code() );
     127        }
     128
     129        public function test_validate_recovery_mode_cookie_returns_wp_error_if_created_at_is_invalid_format() {
     130                $service    = new WP_Recovery_Mode_Cookie_Service();
     131                $reflection = new ReflectionMethod( $service, 'recovery_mode_hash' );
     132                $reflection->setAccessible( true );
     133
     134                $to_sign = sprintf( 'recovery_mode|%s|%s', 'month', wp_generate_password( 20, false ) );
     135                $signed  = $reflection->invoke( $service, $to_sign );
     136                $cookie  = base64_encode( sprintf( '%s|%s', $to_sign, $signed ) );
     137
     138                $error = $service->validate_cookie( $cookie );
     139                $this->assertWPError( $error );
     140                $this->assertEquals( 'invalid_created_at', $error->get_error_code() );
     141        }
     142
     143        public function test_generate_and_validate_recovery_mode_cookie_returns_true_for_valid_cookie() {
     144
     145                $service    = new WP_Recovery_Mode_Cookie_Service();
     146                $reflection = new ReflectionMethod( $service, 'generate_cookie' );
     147                $reflection->setAccessible( true );
     148
     149                $this->assertTrue( $service->validate_cookie( $reflection->invoke( $service ) ) );
     150        }
     151}
  • tests/phpunit/tests/user/capabilities.php

    diff --git a/tests/phpunit/tests/user/capabilities.php b/tests/phpunit/tests/user/capabilities.php
    index 3bc5264d52..a326d8ad86 100644
    a b class Tests_User_Capabilities extends WP_UnitTestCase { 
    257257                        'export_others_personal_data' => array( 'administrator' ),
    258258                        'erase_others_personal_data'  => array( 'administrator' ),
    259259                        'manage_privacy_options'      => array( 'administrator' ),
     260                        'resume_plugins'              => array( 'administrator' ),
     261                        'resume_themes'               => array( 'administrator' ),
    260262
    261263                        'edit_categories'             => array( 'administrator', 'editor' ),
    262264                        'delete_categories'           => array( 'administrator', 'editor' ),
    class Tests_User_Capabilities extends WP_UnitTestCase { 
    296298                        'customize'                   => array( 'administrator' ),
    297299                        'delete_site'                 => array( 'administrator' ),
    298300                        'add_users'                   => array( 'administrator' ),
     301                        'resume_plugins'              => array( 'administrator' ),
     302                        'resume_themes'               => array( 'administrator' ),
    299303
    300304                        'edit_categories'             => array( 'administrator', 'editor' ),
    301305                        'delete_categories'           => array( 'administrator', 'editor' ),
    class Tests_User_Capabilities extends WP_UnitTestCase { 
    454458                        // Singular object meta capabilities (where an object ID is passed) are not tested:
    455459                        $expected['activate_plugin'],
    456460                        $expected['deactivate_plugin'],
     461                        $expected['resume_plugin'],
    457462                        $expected['remove_user'],
    458463                        $expected['promote_user'],
    459464                        $expected['edit_user'],