Make WordPress Core

Ticket #46130: 46130.3.diff

File 46130.3.diff, 83.4 KB (added by flixos90, 5 years 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  
    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 f83473af4d..cd4e6ea883 100644
    a b public function __construct( $args = array() ) { 
    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
    public function prepare_items() { 
    9999                        'upgrade'            => array(),
    100100                        'mustuse'            => array(),
    101101                        'dropins'            => array(),
     102                        'paused'             => array(),
    102103                );
    103104
    104105                $screen = $this->screen;
    public function prepare_items() { 
    183184                foreach ( (array) $plugins['all'] as $plugin_file => $plugin_data ) {
    184185                        // Extra info if known. array_merge() ensures $plugin_data has precedence if keys collide.
    185186                        if ( isset( $plugin_info->response[ $plugin_file ] ) ) {
    186                                 $plugins['all'][ $plugin_file ] = $plugin_data = array_merge( (array) $plugin_info->response[ $plugin_file ], $plugin_data );
     187                                $plugin_data                    = array_merge( (array) $plugin_info->response[ $plugin_file ], $plugin_data );
     188                                $plugins['all'][ $plugin_file ] = $plugin_data;
    187189                                // Make sure that $plugins['upgrade'] also receives the extra info since it is used on ?plugin_status=upgrade
    188190                                if ( isset( $plugins['upgrade'][ $plugin_file ] ) ) {
    189                                         $plugins['upgrade'][ $plugin_file ] = $plugin_data = array_merge( (array) $plugin_info->response[ $plugin_file ], $plugin_data );
     191                                        $plugins['upgrade'][ $plugin_file ] = $plugin_data;
    190192                                }
    191193                        } elseif ( isset( $plugin_info->no_update[ $plugin_file ] ) ) {
    192                                 $plugins['all'][ $plugin_file ] = $plugin_data = array_merge( (array) $plugin_info->no_update[ $plugin_file ], $plugin_data );
     194                                $plugin_data                    = array_merge( (array) $plugin_info->no_update[ $plugin_file ], $plugin_data );
     195                                $plugins['all'][ $plugin_file ] = $plugin_data;
    193196                                // Make sure that $plugins['upgrade'] also receives the extra info since it is used on ?plugin_status=upgrade
    194197                                if ( isset( $plugins['upgrade'][ $plugin_file ] ) ) {
    195                                         $plugins['upgrade'][ $plugin_file ] = $plugin_data = array_merge( (array) $plugin_info->no_update[ $plugin_file ], $plugin_data );
     198                                        $plugins['upgrade'][ $plugin_file ] = $plugin_data;
    196199                                }
    197200                        }
    198201
    public function prepare_items() { 
    218221                                // On the non-network screen, populate the active list with plugins that are individually activated
    219222                                // On the network-admin screen, populate the active list with plugins that are network activated
    220223                                $plugins['active'][ $plugin_file ] = $plugin_data;
     224
     225                                if ( ! $screen->in_admin( 'network' ) && 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
    protected function get_views() { 
    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 );
    public function single_row( $item ) { 
    657668                                                /* translators: %s: plugin name */
    658669                                                $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>';
    659670                                        }
     671                                        if ( current_user_can( 'resume_plugin', $plugin_file ) && is_plugin_paused( $plugin_file ) ) {
     672                                                /* translators: %s: plugin name */
     673                                                $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>';
     674                                        }
    660675                                } else {
    661676                                        if ( current_user_can( 'activate_plugin', $plugin_file ) ) {
    662677                                                /* translators: %s: plugin name */
    public function single_row( $item ) { 
    765780                        $class .= ' update';
    766781                }
    767782
     783                $paused = ! $screen->in_admin( 'network' ) && is_plugin_paused( $plugin_file );
     784
     785                if ( $paused ) {
     786                        $class .= ' paused';
     787                }
     788
    768789                $plugin_slug = isset( $plugin_data['slug'] ) ? $plugin_data['slug'] : sanitize_title( $plugin_name );
    769790                printf(
    770791                        '<tr class="%s" data-slug="%s" data-plugin="%s">',
    public function single_row( $item ) { 
    846867                                         * @param array    $plugin_data An array of plugin data.
    847868                                         * @param string   $status      Status of the plugin. Defaults are 'All', 'Active',
    848869                                         *                              'Inactive', 'Recently Activated', 'Upgrade', 'Must-Use',
    849                                          *                              'Drop-ins', 'Search'.
     870                                         *                              'Drop-ins', 'Search', 'Paused'.
    850871                                         */
    851872                                        $plugin_meta = apply_filters( 'plugin_row_meta', $plugin_meta, $plugin_file, $plugin_data, $status );
    852873                                        echo implode( ' | ', $plugin_meta );
    853874
    854875                                        echo '</div>';
    855876
     877                                        if ( $paused ) {
     878                                                $notice_text = __( 'This plugin failed to load properly and is paused during recovery mode.' );
     879
     880                                                printf( '<p><span class="dashicons dashicons-warning"></span> <strong>%s</strong></p>', $notice_text );
     881
     882                                                $error = wp_get_plugin_error( $plugin_file );
     883
     884                                                if ( false !== $error ) {
     885                                                        printf( '<div class="error-display"><p>%s</p></div>', wp_get_extension_error_description( $error ) );
     886                                                }
     887                                        }
     888
    856889                                        echo '</td>';
    857890                                        break;
    858891                                default:
    public function single_row( $item ) { 
    886919                 * @param array  $plugin_data An array of plugin data.
    887920                 * @param string $status      Status of the plugin. Defaults are 'All', 'Active',
    888921                 *                            'Inactive', 'Recently Activated', 'Upgrade', 'Must-Use',
    889                  *                            'Drop-ins', 'Search'.
     922                 *                            'Drop-ins', 'Search', 'Paused'.
    890923                 */
    891924                do_action( 'after_plugin_row', $plugin_file, $plugin_data, $status );
    892925
    public function single_row( $item ) { 
    902935                 * @param array  $plugin_data An array of plugin data.
    903936                 * @param string $status      Status of the plugin. Defaults are 'All', 'Active',
    904937                 *                            'Inactive', 'Recently Activated', 'Upgrade', 'Must-Use',
    905                  *                            'Drop-ins', 'Search'.
     938                 *                            'Drop-ins', 'Search', 'Paused'.
    906939                 */
    907940                do_action( "after_plugin_row_{$plugin_file}", $plugin_file, $plugin_data, $status );
    908941        }
  • src/wp-admin/includes/plugin.php

    diff --git a/src/wp-admin/includes/plugin.php b/src/wp-admin/includes/plugin.php
    index 5873a33466..f44b5aff02 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
    477479        );
    478480
    479481        if ( is_multisite() ) {
    function wp_add_privacy_policy_content( $plugin_name, $policy_text ) { 
    21012103
    21022104        WP_Privacy_Policy_Content::add( $plugin_name, $policy_text );
    21032105}
     2106
     2107/**
     2108 * Determines whether a plugin is technically active but was paused while
     2109 * loading.
     2110 *
     2111 * For more information on this and similar theme functions, check out
     2112 * the {@link https://developer.wordpress.org/themes/basics/conditional-tags/
     2113 * Conditional Tags} article in the Theme Developer Handbook.
     2114 *
     2115 * @since 5.2.0
     2116 *
     2117 * @param string $plugin Path to the plugin file relative to the plugins directory.
     2118 * @return bool True, if in the list of paused plugins. False, not in the list.
     2119 */
     2120function is_plugin_paused( $plugin ) {
     2121        if ( ! isset( $GLOBALS['_paused_plugins'] ) ) {
     2122                return false;
     2123        }
     2124
     2125        if ( ! is_plugin_active( $plugin ) ) {
     2126                return false;
     2127        }
     2128
     2129        list( $plugin ) = explode( '/', $plugin );
     2130
     2131        return array_key_exists( $plugin, $GLOBALS['_paused_plugins'] );
     2132}
     2133
     2134/**
     2135 * Gets the error that was recorded for a paused plugin.
     2136 *
     2137 * @since 5.2.0
     2138 *
     2139 * @param string $plugin Path to the plugin file relative to the plugins
     2140 *                       directory.
     2141 * @return array|false Array of error information as it was returned by
     2142 *                     `error_get_last()`, or false if none was recorded.
     2143 */
     2144function wp_get_plugin_error( $plugin ) {
     2145        if ( ! isset( $GLOBALS['_paused_plugins'] ) ) {
     2146                return false;
     2147        }
     2148
     2149        list( $plugin ) = explode( '/', $plugin );
     2150
     2151        if ( ! array_key_exists( $plugin, $GLOBALS['_paused_plugins'] ) ) {
     2152                return false;
     2153        }
     2154
     2155        return $GLOBALS['_paused_plugins'][ $plugin ];
     2156}
     2157
     2158/**
     2159 * Tries to resume a single plugin.
     2160 *
     2161 * If a redirect was provided, we first ensure the plugin does not throw fatal
     2162 * errors anymore.
     2163 *
     2164 * The way it works is by setting the redirection to the error before trying to
     2165 * include the plugin file. If the plugin fails, then the redirection will not
     2166 * be overwritten with the success message and the plugin will not be resumed.
     2167 *
     2168 * @since 5.2.0
     2169 *
     2170 * @param string $plugin       Single plugin to resume.
     2171 * @param string $redirect     Optional. URL to redirect to. Default empty string.
     2172 * @return bool|WP_Error True on success, false if `$plugin` was not paused,
     2173 *                       `WP_Error` on failure.
     2174 */
     2175function resume_plugin( $plugin, $redirect = '' ) {
     2176        /*
     2177         * We'll override this later if the plugin could be resumed without
     2178         * creating a fatal error.
     2179         */
     2180        if ( ! empty( $redirect ) ) {
     2181                wp_redirect(
     2182                        add_query_arg(
     2183                                '_error_nonce',
     2184                                wp_create_nonce( 'plugin-resume-error_' . $plugin ),
     2185                                $redirect
     2186                        )
     2187                );
     2188
     2189                // Load the plugin to test whether it throws a fatal error.
     2190                ob_start();
     2191                plugin_sandbox_scrape( $plugin );
     2192                ob_clean();
     2193        }
     2194
     2195        list( $extension ) = explode( '/', $plugin );
     2196
     2197        $result = wp_paused_plugins()->delete( $extension );
     2198
     2199        if ( ! $result ) {
     2200                return new WP_Error(
     2201                        'could_not_resume_plugin',
     2202                        __( 'Could not resume the plugin.' )
     2203                );
     2204        }
     2205
     2206        return true;
     2207}
     2208
     2209/**
     2210 * Renders an admin notice in case some plugins have been paused due to errors.
     2211 *
     2212 * @since 5.2.0
     2213 */
     2214function paused_plugins_notice() {
     2215        if ( 'plugins.php' === $GLOBALS['pagenow'] ) {
     2216                return;
     2217        }
     2218
     2219        if ( ! current_user_can( 'resume_plugins' ) ) {
     2220                return;
     2221        }
     2222
     2223        if ( ! isset( $GLOBALS['_paused_plugins'] ) || empty( $GLOBALS['_paused_plugins'] ) ) {
     2224                return;
     2225        }
     2226
     2227        printf(
     2228                '<div class="notice notice-error"><p><strong>%s</strong><br>%s</p><p><a href="%s">%s</a></p></div>',
     2229                __( 'One or more plugins failed to load properly.' ),
     2230                __( 'You can find more details and make changes on the Plugins screen.' ),
     2231                esc_url( admin_url( 'plugins.php?plugin_status=paused' ) ),
     2232                __( 'Go to the Plugins screen' )
     2233        );
     2234}
  • src/wp-admin/includes/theme.php

    diff --git a/src/wp-admin/includes/theme.php b/src/wp-admin/includes/theme.php
    index 91a3577906..c0f72b3d42 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 * Tries to resume a single theme.
     821 *
     822 * If a redirect was provided and a functions.php file was found, we first ensure that
     823 * functions.php file does not throw fatal errors anymore.
     824 *
     825 * The way it works is by setting the redirection to the error before trying to
     826 * include the file. If the theme fails, then the redirection will not be overwritten
     827 * with the success message and the theme will not be resumed.
     828 *
     829 * @since 5.2.0
     830 *
     831 * @param string $theme    Single theme to resume.
     832 * @param string $redirect Optional. URL to redirect to. Default empty string.
     833 * @return bool|WP_Error True on success, false if `$theme` was not paused,
     834 *                       `WP_Error` on failure.
     835 */
     836function resume_theme( $theme, $redirect = '' ) {
     837        list( $extension ) = explode( '/', $theme );
     838
     839        /*
     840         * We'll override this later if the theme could be resumed without
     841         * creating a fatal error.
     842         */
     843        if ( ! empty( $redirect ) ) {
     844                $functions_path = '';
     845                if ( strpos( STYLESHEETPATH, $extension ) ) {
     846                        $functions_path = STYLESHEETPATH . '/functions.php';
     847                } elseif ( strpos( TEMPLATEPATH, $extension ) ) {
     848                        $functions_path = TEMPLATEPATH . '/functions.php';
     849                }
     850
     851                if ( ! empty( $functions_path ) ) {
     852                        wp_redirect(
     853                                add_query_arg(
     854                                        '_error_nonce',
     855                                        wp_create_nonce( 'theme-resume-error_' . $theme ),
     856                                        $redirect
     857                                )
     858                        );
     859
     860                        // Load the theme's functions.php to test whether it throws a fatal error.
     861                        ob_start();
     862                        include $functions_path;
     863                        ob_clean();
     864                }
     865        }
     866
     867        $result = wp_paused_themes()->delete( $extension );
     868
     869        if ( ! $result ) {
     870                return new WP_Error(
     871                        'could_not_resume_theme',
     872                        __( 'Could not resume the theme.' )
     873                );
     874        }
     875
     876        return true;
     877}
     878
     879/**
     880 * Renders an admin notice in case some themes have been paused due to errors.
     881 *
     882 * @since 5.2.0
     883 */
     884function paused_themes_notice() {
     885        if ( 'themes.php' === $GLOBALS['pagenow'] ) {
     886                return;
     887        }
     888
     889        if ( ! current_user_can( 'resume_themes' ) ) {
     890                return;
     891        }
     892
     893        if ( ! isset( $GLOBALS['_paused_themes'] ) || empty( $GLOBALS['_paused_themes'] ) ) {
     894                return;
     895        }
     896
     897        printf(
     898                '<div class="notice notice-error"><p><strong>%s</strong><br>%s</p><p><a href="%s">%s</a></p></div>',
     899                __( 'One or more themes failed to load properly.' ),
     900                __( 'You can find more details and make changes on the Themes screen.' ),
     901                esc_url( admin_url( 'themes.php' ) ),
     902                __( 'Go to the Themes screen' )
     903        );
     904}
  • src/wp-admin/plugins.php

    diff --git a/src/wp-admin/plugins.php b/src/wp-admin/plugins.php
    index c80e96831f..ececdfc4d0 100644
    a b  
    389389                        }
    390390                        break;
    391391
     392                case 'resume':
     393                        if ( is_multisite() ) {
     394                                return;
     395                        }
     396
     397                        if ( ! current_user_can( 'resume_plugin', $plugin ) ) {
     398                                wp_die( __( 'Sorry, you are not allowed to resume this plugin.' ) );
     399                        }
     400
     401                        check_admin_referer( 'resume-plugin_' . $plugin );
     402
     403                        $result = resume_plugin( $plugin, self_admin_url( "plugins.php?error=resuming&plugin_status=$status&paged=$page&s=$s" ) );
     404
     405                        if ( is_wp_error( $result ) ) {
     406                                wp_die( $result );
     407                        }
     408
     409                        wp_redirect( self_admin_url( "plugins.php?resume=true&plugin_status=$status&paged=$page&s=$s" ) );
     410                        exit;
     411
    392412                default:
    393413                        if ( isset( $_POST['checked'] ) ) {
    394414                                check_admin_referer( 'bulk-plugins' );
     
    488508                        $_GET['charsout']
    489509                );
    490510                $errmsg .= ' ' . __( 'If you notice &#8220;headers already sent&#8221; messages, problems with syndication feeds or other issues, try deactivating or removing this plugin.' );
     511        } elseif ( 'resuming' === $_GET['error'] ) {
     512                $errmsg = __( 'Plugin could not be resumed because it triggered a <strong>fatal error</strong>.' );
    491513        } else {
    492514                $errmsg = __( 'Plugin could not be activated because it triggered a <strong>fatal error</strong>.' );
    493515        }
     
    541563        <div id="message" class="updated notice is-dismissible"><p><?php _e( 'Selected plugins <strong>deactivated</strong>.' ); ?></p></div>
    542564<?php elseif ( 'update-selected' == $action ) : ?>
    543565        <div id="message" class="updated notice is-dismissible"><p><?php _e( 'All selected plugins are up to date.' ); ?></p></div>
     566<?php elseif ( isset( $_GET['resume'] ) ) : ?>
     567        <div id="message" class="updated notice is-dismissible"><p><?php _e( 'Plugin <strong>resumed</strong>.' ); ?></p></div>
    544568<?php endif; ?>
    545569
    546570<div class="wrap">
  • src/wp-admin/themes.php

    diff --git a/src/wp-admin/themes.php b/src/wp-admin/themes.php
    index 494e952184..7b0d3b2022 100644
    a b  
    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_theme', $_GET['stylesheet'] ) ) {
     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(), self_admin_url( 'themes.php?error=resuming' ) );
     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'] );
     
    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
     222} elseif ( isset( $_GET['error'] ) && 'resuming' === $_GET['error'] ) {
     223        ?>
     224        <div id="message6" class="error"><p><?php _e( 'Theme could not be resumed because it triggered a <strong>fatal error</strong>.' ); ?></p></div>
     225        <?php
    198226}
    199227
    200228$ct = wp_get_theme();
     
    348376<p><?php _e( 'The following themes are installed but incomplete.' ); ?></p>
    349377
    350378        <?php
     379        $can_resume  = current_user_can( 'resume_themes' );
    351380        $can_delete  = current_user_can( 'delete_themes' );
    352381        $can_install = current_user_can( 'install_themes' );
    353382        ?>
     
    355384        <tr>
    356385                <th><?php _ex( 'Name', 'theme name' ); ?></th>
    357386                <th><?php _e( 'Description' ); ?></th>
     387                <?php if ( $can_resume ) { ?>
     388                        <td></td>
     389                <?php } ?>
    358390                <?php if ( $can_delete ) { ?>
    359391                        <td></td>
    360392                <?php } ?>
     
    367399                        <td><?php echo $broken_theme->get( 'Name' ) ? $broken_theme->display( 'Name' ) : $broken_theme->get_stylesheet(); ?></td>
    368400                        <td><?php echo $broken_theme->errors()->get_error_message(); ?></td>
    369401                        <?php
     402                        if ( $can_resume ) {
     403                                if ( 'theme_paused' === $broken_theme->errors()->get_error_code() ) {
     404                                        $stylesheet = $broken_theme->get_stylesheet();
     405                                        $resume_url = add_query_arg(
     406                                                array(
     407                                                        'action'     => 'resume',
     408                                                        'stylesheet' => urlencode( $stylesheet ),
     409                                                ),
     410                                                admin_url( 'themes.php' )
     411                                        );
     412                                        $resume_url = wp_nonce_url( $resume_url, 'resume-theme_' . $stylesheet );
     413                                        ?>
     414                                        <td><a href="<?php echo esc_url( $resume_url ); ?>" class="button resume-theme"><?php _e( 'Resume' ); ?></a></td>
     415                                        <?php
     416                                } else {
     417                                        ?>
     418                                        <td></td>
     419                                        <?php
     420                                }
     421                        }
     422
    370423                        if ( $can_delete ) {
    371424                                $stylesheet = $broken_theme->get_stylesheet();
    372425                                $delete_url = add_query_arg(
  • src/wp-includes/admin-bar.php

    diff --git a/src/wp-includes/admin-bar.php b/src/wp-includes/admin-bar.php
    index 6511937c3b..6600c651df 100644
    a b function wp_admin_bar_search_menu( $wp_admin_bar ) { 
    10471047        );
    10481048}
    10491049
     1050/**
     1051 * Add a link to exit recovery mode when Recovery Mode is active.
     1052 *
     1053 * @since 5.2.0
     1054 *
     1055 * @param WP_Admin_Bar $wp_admin_bar
     1056 */
     1057function wp_admin_bar_recovery_mode_menu( $wp_admin_bar ) {
     1058        if ( ! wp_is_recovery_mode() ) {
     1059                return;
     1060        }
     1061
     1062        $url = wp_login_url();
     1063        $url = add_query_arg( 'action', WP_Recovery_Mode::EXIT_ACTION, $url );
     1064        $url = wp_nonce_url( $url, WP_Recovery_Mode::EXIT_ACTION );
     1065
     1066        $wp_admin_bar->add_menu(
     1067                array(
     1068                        'parent' => 'top-secondary',
     1069                        'id'     => 'recovery-mode',
     1070                        'title'  => __( 'Exit Recovery Mode' ),
     1071                        'href'   => $url,
     1072                        'meta'   => array(
     1073                                'title' => __( 'Exit Recovery Mode' ),
     1074                        ),
     1075                )
     1076        );
     1077}
     1078
    10501079/**
    10511080 * Add secondary menus.
    10521081 *
  • src/wp-includes/capabilities.php

    diff --git a/src/wp-includes/capabilities.php b/src/wp-includes/capabilities.php
    index c192639608..a039f0daef 100644
    a b function map_meta_cap( $cap, $user_id ) { 
    464464                                }
    465465                        }
    466466                        break;
     467                case 'resume_plugin':
     468                        $caps[] = 'resume_plugins';
     469                        break;
     470                case 'resume_theme':
     471                        $caps[] = 'resume_themes';
     472                        break;
    467473                case 'delete_user':
    468474                case 'delete_users':
    469475                        // If multisite only super admins can delete users.
    function wp_maybe_grant_install_languages_cap( $allcaps ) { 
    950956
    951957        return $allcaps;
    952958}
     959
     960/**
     961 * Filters the user capabilities to grant the 'resume_plugins' and 'resume_themes' capabilities as necessary.
     962 *
     963 * @since 5.2.0
     964 *
     965 * @param bool[] $allcaps An array of all the user's capabilities.
     966 * @return bool[] Filtered array of the user's capabilities.
     967 */
     968function wp_maybe_grant_resume_extensions_caps( $allcaps ) {
     969        // Even in a multisite, regular administrators should be able to resume plugins.
     970        if ( ! empty( $allcaps['activate_plugins'] ) ) {
     971                $allcaps['resume_plugins'] = true;
     972        }
     973
     974        // Even in a multisite, regular administrators should be able to resume themes.
     975        if ( ! empty( $allcaps['switch_themes'] ) ) {
     976                $allcaps['resume_themes'] = true;
     977        }
     978
     979        return $allcaps;
     980}
  • src/wp-includes/class-wp-admin-bar.php

    diff --git a/src/wp-includes/class-wp-admin-bar.php b/src/wp-includes/class-wp-admin-bar.php
    index c7813a37e5..9b2982c27e 100644
    a b public function add_menus() { 
    596596                add_action( 'admin_bar_menu', 'wp_admin_bar_my_account_menu', 0 );
    597597                add_action( 'admin_bar_menu', 'wp_admin_bar_search_menu', 4 );
    598598                add_action( 'admin_bar_menu', 'wp_admin_bar_my_account_item', 7 );
     599                add_action( 'admin_bar_menu', 'wp_admin_bar_recovery_mode_menu', 8 );
    599600
    600601                // Site related.
    601602                add_action( 'admin_bar_menu', 'wp_admin_bar_sidebar_toggle', 0 );
  • 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
    index e36a8f68e8..e44dfed598 100644
    a b public function handle() { 
    3838                                return;
    3939                        }
    4040
     41                        if ( ! is_multisite() && wp_recovery_mode()->is_initialized() ) {
     42                                wp_recovery_mode()->handle_error( $error );
     43                        }
     44
    4145                        // Display the PHP error template.
    4246                        $this->display_error_template();
    4347                } catch ( Exception $e ) {
  • 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..7c51db8781
    - +  
     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         * Type of extension. Used to key extension storage.
     18         *
     19         * @since 5.2.0
     20         * @var string
     21         */
     22        protected $type;
     23
     24        /**
     25         * Constructor.
     26         *
     27         * @since 5.2.0
     28         *
     29         * @param string $extension_type Extension type. Either 'plugin' or 'theme'.
     30         */
     31        public function __construct( $extension_type ) {
     32                $this->type = $extension_type;
     33        }
     34
     35        /**
     36         * Records an extension error.
     37         *
     38         * Only one error is stored per extension, with subsequent errors for the same extension overriding the
     39         * previously stored error.
     40         *
     41         * @since 5.2.0
     42         *
     43         * @param string $extension Plugin or theme directory name.
     44         * @param array  $error     {
     45         *     Error that was triggered.
     46         *
     47         *     @type string $type    The error type.
     48         *     @type string $file    The name of the file in which the error occurred.
     49         *     @type string $line    The line number in which the error occurred.
     50         *     @type string $message The error message.
     51         * }
     52         * @return bool True on success, false on failure.
     53         */
     54        public function set( $extension, $error ) {
     55                if ( ! $this->is_api_loaded() ) {
     56                        return false;
     57                }
     58
     59                $option_name = $this->get_option_name();
     60
     61                if ( ! $option_name ) {
     62                        return false;
     63                }
     64
     65                $paused_extensions = (array) get_option( $option_name, array() );
     66
     67                // Do not update if the error is already stored.
     68                if ( isset( $paused_extensions[ $this->type ][ $extension ] ) && $paused_extensions[ $this->type ][ $extension ] === $error ) {
     69                        return true;
     70                }
     71
     72                $paused_extensions[ $this->type ][ $extension ] = $error;
     73
     74                return update_option( $option_name, $paused_extensions );
     75        }
     76
     77        /**
     78         * Forgets a previously recorded extension error.
     79         *
     80         * @since 5.2.0
     81         *
     82         * @param string $extension Plugin or theme directory name.
     83         *
     84         * @return bool True on success, false on failure.
     85         */
     86        public function delete( $extension ) {
     87                if ( ! $this->is_api_loaded() ) {
     88                        return false;
     89                }
     90
     91                $option_name = $this->get_option_name();
     92
     93                if ( ! $option_name ) {
     94                        return false;
     95                }
     96
     97                $paused_extensions = (array) get_option( $option_name, array() );
     98
     99                // Do not delete if no error is stored.
     100                if ( ! isset( $paused_extensions[ $this->type ][ $extension ] ) ) {
     101                        return true;
     102                }
     103
     104                unset( $paused_extensions[ $this->type ][ $extension ] );
     105
     106                if ( empty( $paused_extensions[ $this->type ] ) ) {
     107                        unset( $paused_extensions[ $this->type ] );
     108                }
     109
     110                // Clean up the entire option if we're removing the only error.
     111                if ( ! $paused_extensions ) {
     112                        return delete_option( $option_name );
     113                }
     114
     115                return update_option( $option_name, $paused_extensions );
     116        }
     117
     118        /**
     119         * Gets the error for an extension, if paused.
     120         *
     121         * @since 5.2.0
     122         *
     123         * @param string $extension Plugin or theme directory name.
     124         *
     125         * @return array|null Error that is stored, or null if the extension is not paused.
     126         */
     127        public function get( $extension ) {
     128                if ( ! $this->is_api_loaded() ) {
     129                        return null;
     130                }
     131
     132                $paused_extensions = $this->get_all();
     133
     134                if ( ! isset( $paused_extensions[ $extension ] ) ) {
     135                        return null;
     136                }
     137
     138                return $paused_extensions[ $extension ];
     139        }
     140
     141        /**
     142         * Gets the paused extensions with their errors.
     143         *
     144         * @since 5.2.0
     145         *
     146         * @return array Associative array of extension slugs to the error recorded.
     147         */
     148        public function get_all() {
     149                if ( ! $this->is_api_loaded() ) {
     150                        return array();
     151                }
     152
     153                $option_name = $this->get_option_name();
     154
     155                if ( ! $option_name ) {
     156                        return array();
     157                }
     158
     159                $paused_extensions = (array) get_option( $option_name, array() );
     160
     161                return isset( $paused_extensions[ $this->type ] ) ? $paused_extensions[ $this->type ] : array();
     162        }
     163
     164        /**
     165         * Remove all paused extensions.
     166         *
     167         * @since 5.2.0
     168         *
     169         * @return bool
     170         */
     171        public function delete_all() {
     172                if ( ! $this->is_api_loaded() ) {
     173                        return false;
     174                }
     175
     176                $option_name = $this->get_option_name();
     177
     178                if ( ! $option_name ) {
     179                        return false;
     180                }
     181
     182                $paused_extensions = (array) get_option( $option_name, array() );
     183
     184                unset( $paused_extensions[ $this->type ] );
     185
     186                if ( ! $paused_extensions ) {
     187                        return delete_option( $option_name );
     188                }
     189
     190                return update_option( $option_name, $paused_extensions );
     191        }
     192
     193        /**
     194         * Checks whether the underlying API to store paused extensions is loaded.
     195         *
     196         * @since 5.2.0
     197         *
     198         * @return bool True if the API is loaded, false otherwise.
     199         */
     200        protected function is_api_loaded() {
     201                return function_exists( 'get_option' );
     202        }
     203
     204        /**
     205         * Get the option name for storing paused extensions.
     206         *
     207         * @since 5.2.0
     208         *
     209         * @return string
     210         */
     211        protected function get_option_name() {
     212                if ( ! wp_recovery_mode()->is_active() ) {
     213                        return '';
     214                }
     215
     216                $session_id = wp_recovery_mode()->get_session_id();
     217                if ( empty( $session_id ) ) {
     218                        return '';
     219                }
     220
     221                return "{$session_id}_paused_extensions";
     222        }
     223}
  • 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..c42dbb105c
    - +  
     1<?php
     2/**
     3 * Error Protection API: WP_Recovery_Mode_Cookie_Service class
     4 *
     5 * @package WordPress
     6 * @since   5.2.0
     7 */
     8
     9/**
     10 * Core class used to set, validate, and clear cookies that identify a Recovery Mode session.
     11 *
     12 * @since 5.2.0
     13 */
     14final class WP_Recovery_Mode_Cookie_Service {
     15
     16        /**
     17         * The cookie name to use.
     18         *
     19         * @since 5.2.0
     20         * @var string
     21         */
     22        private $name;
     23
     24        /**
     25         * The domain the cookie should be set on, {@see setcookie()}.
     26         *
     27         * @since 5.2.0
     28         * @var string
     29         */
     30        private $domain;
     31
     32        /**
     33         * The path to limit the cookie to, {@see setcookie()}.
     34         *
     35         * @since 5.2.0
     36         * @var string
     37         */
     38        private $path;
     39
     40        /**
     41         * The path to use when the home_url and site_url are different.
     42         *
     43         * @since 5.2.0
     44         * @var string
     45         */
     46        private $site_path;
     47
     48        /**
     49         * WP_Recovery_Mode_Cookie_Service constructor.
     50         *
     51         * @since 5.2.0
     52         *
     53         * @param array $opts {
     54         *     Recovery mode cookie options.
     55         *
     56         *     @type string $name      Cookie name.
     57         *     @type string $domain    Cookie domain.
     58         *     @type string $path      Cookie path.
     59         *     @type string $site_path Site cookie path.
     60         * }
     61         */
     62        public function __construct( array $opts = array() ) {
     63                $opts = wp_parse_args(
     64                        $opts,
     65                        array(
     66                                'name'      => RECOVERY_MODE_COOKIE,
     67                                'domain'    => COOKIE_DOMAIN,
     68                                'path'      => COOKIEPATH,
     69                                'site_path' => SITECOOKIEPATH,
     70                        )
     71                );
     72
     73                $this->name      = $opts['name'];
     74                $this->domain    = $opts['domain'];
     75                $this->path      = $opts['path'];
     76                $this->site_path = $opts['site_path'];
     77        }
     78
     79        /**
     80         * Checks whether the recovery mode cookie is set.
     81         *
     82         * @since 5.2.0
     83         *
     84         * @return bool True if the cookie is set, false otherwise.
     85         */
     86        public function is_cookie_set() {
     87                return ! empty( $_COOKIE[ $this->name ] );
     88        }
     89
     90        /**
     91         * Sets the recovery mode cookie.
     92         *
     93         * This must be immediately followed by exiting the request.
     94         *
     95         * @since 5.2.0
     96         */
     97        public function set_cookie() {
     98
     99                $value = $this->generate_cookie();
     100
     101                setcookie( $this->name, $value, 0, $this->path, $this->domain, is_ssl(), true );
     102
     103                if ( $this->path !== $this->site_path ) {
     104                        setcookie( $this->name, $value, 0, $this->site_path, $this->domain, is_ssl(), true );
     105                }
     106        }
     107
     108        /**
     109         * Clears the recovery mode cookie.
     110         *
     111         * @since 5.2.0
     112         */
     113        public function clear_cookie() {
     114                setcookie( $this->name, ' ', time() - YEAR_IN_SECONDS, $this->path, $this->domain );
     115                setcookie( $this->name, ' ', time() - YEAR_IN_SECONDS, $this->site_path, $this->domain );
     116        }
     117
     118        /**
     119         * Validates the recovery mode cookie.
     120         *
     121         * @since 5.2.0
     122         *
     123         * @param string $cookie Optionally specify the cookie string.
     124         *                       If omitted, it will be retrieved from the super global.
     125         * @return true|WP_Error True on success, error object on failure.
     126         */
     127        public function validate_cookie( $cookie = '' ) {
     128
     129                if ( ! $cookie ) {
     130                        if ( empty( $_COOKIE[ $this->name ] ) ) {
     131                                return new WP_Error( 'no_cookie', __( 'No cookie present.' ) );
     132                        }
     133
     134                        $cookie = $_COOKIE[ $this->name ];
     135                }
     136
     137                $parts = $this->parse_cookie( $cookie );
     138
     139                if ( is_wp_error( $parts ) ) {
     140                        return $parts;
     141                }
     142
     143                list( , $created_at, $random, $signature ) = $parts;
     144
     145                if ( ! ctype_digit( $created_at ) ) {
     146                        return new WP_Error( 'invalid_created_at', __( 'Invalid cookie format.' ) );
     147                }
     148
     149                /**
     150                 * Filter the length of time a Recovery Mode cookie is valid for.
     151                 *
     152                 * @since 5.2.0
     153                 *
     154                 * @param int $length Length in seconds.
     155                 */
     156                $length = apply_filters( 'recovery_mode_cookie_length', WEEK_IN_SECONDS );
     157
     158                if ( time() > $created_at + $length ) {
     159                        return new WP_Error( 'expired', __( 'Cookie expired.' ) );
     160                }
     161
     162                $to_sign = sprintf( 'recovery_mode|%s|%s', $created_at, $random );
     163                $hashed  = $this->recovery_mode_hash( $to_sign );
     164
     165                if ( ! hash_equals( $signature, $hashed ) ) {
     166                        return new WP_Error( 'signature_mismatch', __( 'Invalid cookie.' ) );
     167                }
     168
     169                return true;
     170        }
     171
     172        /**
     173         * Gets the session identifier from the cookie.
     174         *
     175         * The cookie should be validated before calling this API.
     176         *
     177         * @since 5.2.0
     178         *
     179         * @param string $cookie Optionally specify the cookie string.
     180         *                       If omitted, it will be retrieved from the super global.
     181         * @return string|WP_Error Session ID on success, or error object on failure.
     182         */
     183        public function get_session_id_from_cookie( $cookie = '' ) {
     184                if ( ! $cookie ) {
     185                        if ( empty( $_COOKIE[ $this->name ] ) ) {
     186                                return new WP_Error( 'no_cookie', __( 'No cookie present.' ) );
     187                        }
     188
     189                        $cookie = $_COOKIE[ $this->name ];
     190                }
     191
     192                $parts = $this->parse_cookie( $cookie );
     193                if ( is_wp_error( $parts ) ) {
     194                        return $parts;
     195                }
     196
     197                list( , , $random ) = $parts;
     198
     199                return sha1( $random );
     200        }
     201
     202        /**
     203         * Parses the cookie into its four parts.
     204         *
     205         * @param string $cookie Cookie content.
     206         * @return array|WP_Error Cookie parts array, or error object on failure.
     207         */
     208        private function parse_cookie( $cookie ) {
     209                $cookie = base64_decode( $cookie );
     210                $parts  = explode( '|', $cookie );
     211
     212                if ( 4 !== count( $parts ) ) {
     213                        return new WP_Error( 'invalid_format', __( 'Invalid cookie format.' ) );
     214                }
     215
     216                return $parts;
     217        }
     218
     219        /**
     220         * Generates the recovery mode cookie value.
     221         *
     222         * The cookie is a base64 encoded string with the following format:
     223         *
     224         * recovery_mode|iat|rand|signature
     225         *
     226         * Where "recovery_mode" is a constant string,
     227         * iat is the time the cookie was generated at,
     228         * rand is a randomly generated password that is also used as a session identifier
     229         * and signature is an hmac of the preceding 3 parts.
     230         *
     231         * @since 5.2.0
     232         *
     233         * @return string Generated cookie content.
     234         */
     235        private function generate_cookie() {
     236                $to_sign = sprintf( 'recovery_mode|%s|%s', time(), wp_generate_password( 20, false ) );
     237                $signed  = $this->recovery_mode_hash( $to_sign );
     238
     239                return base64_encode( sprintf( '%s|%s', $to_sign, $signed ) );
     240        }
     241
     242        /**
     243         * Gets a form of `wp_hash()` specific to Recovery Mode.
     244         *
     245         * We cannot use `wp_hash()` because it is defined in `pluggable.php` which is not loaded until after plugins are loaded,
     246         * which is too late to verify the recovery mode cookie.
     247         *
     248         * This tries to use the `AUTH` salts first, but if they aren't valid specific salts will be generated and stored.
     249         *
     250         * @since 5.2.0
     251         *
     252         * @param string $data Data to hash.
     253         * @return string|false The hashed $data, or false on failure.
     254         */
     255        private function recovery_mode_hash( $data ) {
     256                if ( ! defined( 'AUTH_KEY' ) || AUTH_KEY === 'put your unique phrase here' ) {
     257                        $auth_key = get_site_option( 'recovery_mode_auth_key' );
     258
     259                        if ( ! $auth_key ) {
     260                                if ( ! function_exists( 'wp_generate_password' ) ) {
     261                                        require_once ABSPATH . WPINC . '/pluggable.php';
     262                                }
     263
     264                                $auth_key = wp_generate_password( 64, true, true );
     265                                update_site_option( 'recovery_mode_auth_key', $auth_key );
     266                        }
     267                } else {
     268                        $auth_key = AUTH_KEY;
     269                }
     270
     271                if ( ! defined( 'AUTH_SALT' ) || AUTH_SALT === 'put your unique phrase here' || AUTH_SALT === $auth_key ) {
     272                        $auth_salt = get_site_option( 'recovery_mode_auth_salt' );
     273
     274                        if ( ! $auth_salt ) {
     275                                if ( ! function_exists( 'wp_generate_password' ) ) {
     276                                        require_once ABSPATH . WPINC . '/pluggable.php';
     277                                }
     278
     279                                $auth_salt = wp_generate_password( 64, true, true );
     280                                update_site_option( 'recovery_mode_auth_salt', $auth_salt );
     281                        }
     282                } else {
     283                        $auth_salt = AUTH_SALT;
     284                }
     285
     286                $secret = $auth_key . $auth_salt;
     287
     288                return hash_hmac( 'sha1', $data, $secret );
     289        }
     290}
  • new file src/wp-includes/class-wp-recovery-mode-email-service.php

    diff --git a/src/wp-includes/class-wp-recovery-mode-email-service.php b/src/wp-includes/class-wp-recovery-mode-email-service.php
    new file mode 100644
    index 0000000000..365f2ea279
    - +  
     1<?php
     2/**
     3 * Error Protection API: WP_Recovery_Mode_Email_Link class
     4 *
     5 * @package WordPress
     6 * @since   5.2.0
     7 */
     8
     9/**
     10 * Core class used to send an email with a link to begin Recovery Mode.
     11 *
     12 * @since 5.2.0
     13 */
     14final class WP_Recovery_Mode_Email_Service {
     15
     16        const RATE_LIMIT_OPTION = 'recovery_mode_email_last_sent';
     17
     18        /**
     19         * Service to generate recovery mode URLs.
     20         *
     21         * @since 5.2.0
     22         * @var WP_Recovery_Mode_Link_Service
     23         */
     24        private $link_service;
     25
     26        /**
     27         * WP_Recovery_Mode_Email_Service constructor.
     28         *
     29         * @since 5.2.0
     30         *
     31         * @param WP_Recovery_Mode_Link_Service $link_service
     32         */
     33        public function __construct( WP_Recovery_Mode_Link_Service $link_service ) {
     34                $this->link_service = $link_service;
     35        }
     36
     37        /**
     38         * Sends the recovery mode email if the rate limit has not been sent.
     39         *
     40         * @since 5.2.0
     41         *
     42         * @param int   $rate_limit Number of seconds before another email can be sent.
     43         * @param array $error      Error details from {@see error_get_last()}
     44         * @param array $extension  The extension that caused the error. {
     45         *      @type string $slug The extension slug. The plugin or theme's directory.
     46         *      @type string $type The extension type. Either 'plugin' or 'theme'.
     47         * }
     48         * @return true|WP_Error True if email sent, WP_Error otherwise.
     49         */
     50        public function maybe_send_recovery_mode_email( $rate_limit, $error, $extension ) {
     51
     52                $last_sent = get_option( self::RATE_LIMIT_OPTION );
     53
     54                if ( ! $last_sent || time() > $last_sent + $rate_limit ) {
     55                        if ( ! update_option( self::RATE_LIMIT_OPTION, time() ) ) {
     56                                return new WP_Error( 'storage_error', __( 'Could not update the email last sent time.' ) );
     57                        }
     58
     59                        $sent = $this->send_recovery_mode_email( $rate_limit, $error, $extension );
     60
     61                        if ( $sent ) {
     62                                return true;
     63                        }
     64
     65                        return new WP_Error( 'email_failed', __( 'The email could not be sent. Possible reason: your host may have disabled the mail() function.' ) );
     66                }
     67
     68                $err_message = sprintf(
     69                        /* translators: 1. Last sent as a human time diff 2. Wait time as a human time diff. */
     70                        __( 'A recovery link was already sent %1$s ago. Please wait another %2$s before requesting a new email.' ),
     71                        human_time_diff( $last_sent ),
     72                        human_time_diff( $last_sent + $rate_limit )
     73                );
     74
     75                return new WP_Error( 'email_sent_already', $err_message );
     76        }
     77
     78        /**
     79         * Clears the rate limit, allowing a new recovery mode email to be sent immediately.
     80         *
     81         * @since 5.2.0
     82         *
     83         * @return bool True on success, false on failure.
     84         */
     85        public function clear_rate_limit() {
     86                return delete_option( self::RATE_LIMIT_OPTION );
     87        }
     88
     89        /**
     90         * Sends the Recovery Mode email to the site admin email address.
     91         *
     92         * @since 5.2.0
     93         *
     94         * @param int   $rate_limit Number of seconds before another email can be sent.
     95         * @param array $error      Error details from {@see error_get_last()}
     96         * @param array $extension  Extension that caused the error.
     97         *
     98         * @return bool Whether the email was sent successfully.
     99         */
     100        private function send_recovery_mode_email( $rate_limit, $error, $extension ) {
     101
     102                $url      = $this->link_service->generate_url();
     103                $blogname = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES );
     104
     105                $switched_locale = false;
     106
     107                // The switch_to_locale() function is loaded before it can actually be used.
     108                if ( function_exists( 'switch_to_locale' ) && isset( $GLOBALS['wp_locale_switcher'] ) ) {
     109                        $switched_locale = switch_to_locale( get_locale() );
     110                }
     111
     112                if ( $extension ) {
     113                        $cause   = $this->get_cause( $extension );
     114                        $details = wp_strip_all_tags( wp_get_extension_error_description( $error ) );
     115
     116                        if ( $details ) {
     117                                $header  = __( 'Error Details' );
     118                                $details = "\n\n" . $header . "\n" . str_pad( '', strlen( $header ), '=' ) . "\n" . $details;
     119                        }
     120                } else {
     121                        $cause   = '';
     122                        $details = '';
     123                }
     124
     125                $message = __(
     126                        'Howdy,
     127
     128Your site recently crashed on ###LOCATION### and may not be working as expected.
     129###CAUSE###
     130Click the link below to initiate recovery mode and fix the problem.
     131
     132This link expires in ###EXPIRES###.
     133
     134###LINK### ###DETAILS###
     135'
     136                );
     137                $message = str_replace(
     138                        array(
     139                                '###LINK###',
     140                                '###LOCATION###',
     141                                '###EXPIRES###',
     142                                '###CAUSE###',
     143                                '###DETAILS###',
     144                        ),
     145                        array(
     146                                $url,
     147                                'TBD',
     148                                human_time_diff( time() + $rate_limit ),
     149                                $cause ? "\n{$cause}\n" : "\n",
     150                                $details,
     151                        ),
     152                        $message
     153                );
     154
     155                $email = array(
     156                        'to'      => $this->get_recovery_mode_email_address(),
     157                        /* translators: %s: site name */
     158                        'subject' => __( '[%s] Your Site is Experiencing a Technical Issue' ),
     159                        'message' => $message,
     160                        'headers' => '',
     161                );
     162
     163                /**
     164                 * Filter the contents of the Recovery Mode email.
     165                 *
     166                 * @since 5.2.0
     167                 *
     168                 * @param array  $email Used to build wp_mail().
     169                 * @param string $url   URL to enter recovery mode.
     170                 */
     171                $email = apply_filters( 'recovery_mode_email', $email, $url );
     172
     173                $sent = wp_mail(
     174                        $email['to'],
     175                        wp_specialchars_decode( sprintf( $email['subject'], $blogname ) ),
     176                        $email['message'],
     177                        $email['headers']
     178                );
     179
     180                if ( $switched_locale ) {
     181                        restore_previous_locale();
     182                }
     183
     184                return $sent;
     185        }
     186
     187        /**
     188         * Gets the email address to send the recovery mode link to.
     189         *
     190         * @since 5.2.0
     191         *
     192         * @return string Email address to send recovery mode link to.
     193         */
     194        private function get_recovery_mode_email_address() {
     195                if ( defined( 'RECOVERY_MODE_EMAIL' ) && is_email( RECOVERY_MODE_EMAIL ) ) {
     196                        return RECOVERY_MODE_EMAIL;
     197                }
     198
     199                return get_option( 'admin_email' );
     200        }
     201
     202        /**
     203         * Gets the description indicating the possible cause for the error.
     204         *
     205         * @since 5.2.0
     206         *
     207         * @param array $extension The extension that caused the error.
     208         * @return string Message about which extension caused the error.
     209         */
     210        private function get_cause( $extension ) {
     211
     212                if ( 'plugin' === $extension['type'] ) {
     213                        if ( ! function_exists( 'get_plugins' ) ) {
     214                                require_once ABSPATH . 'wp-admin/includes/plugin.php';
     215                        }
     216
     217                        $plugins = get_plugins();
     218
     219                        $name = '';
     220
     221                        // Assume plugin main file name first since it is a common convention.
     222                        if ( isset( $plugins[ "{$extension['slug']}/{$extension['slug']}.php" ] ) ) {
     223                                $name = $plugins[ "{$extension['slug']}/{$extension['slug']}.php" ]['Name'];
     224                        } else {
     225                                foreach ( $plugins as $file => $plugin_data ) {
     226                                        if ( 0 === strpos( $file, "{$extension['slug']}/" ) ) {
     227                                                $name = $plugin_data['Name'];
     228                                                break;
     229                                        }
     230                                }
     231                        }
     232
     233                        if ( empty( $name ) ) {
     234                                $name = $extension['slug'];
     235                        }
     236
     237                        /* translators: %s: plugin name */
     238                        $cause = sprintf( __( 'This was be caused by the %s plugin.' ), $name );
     239                } else {
     240                        $theme = wp_get_theme( $extension['slug'] );
     241                        $name  = $theme->exists() ? $theme->display( 'Name' ) : $extension['slug'];
     242
     243                        /* translators: %s: theme name */
     244                        $cause = sprintf( __( 'This was be caused by the %s theme.' ), $name );
     245                }
     246
     247                return $cause;
     248        }
     249}
  • 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..06f8d0fedd
    - +  
     1<?php
     2/**
     3 * Error Protection API: WP_Recovery_Mode_Key_service class
     4 *
     5 * @package WordPress
     6 * @since   5.2.0
     7 */
     8
     9/**
     10 * Core class used to generate and validate keys used to enter Recovery Mode.
     11 *
     12 * @since 5.2.0
     13 */
     14final class WP_Recovery_Mode_Key_Service {
     15
     16        /**
     17         * Creates a recovery mode key.
     18         *
     19         * @since 5.2.0
     20         *
     21         * @global PasswordHash $wp_hasher
     22         *
     23         * @return string Recovery mode key.
     24         */
     25        public function generate_and_store_recovery_mode_key() {
     26
     27                global $wp_hasher;
     28
     29                $key = wp_generate_password( 22, false );
     30
     31                /**
     32                 * Fires when a recovery mode key is generated for a user.
     33                 *
     34                 * @since 5.2.0
     35                 *
     36                 * @param string $key The recovery mode key.
     37                 */
     38                do_action( 'generate_recovery_mode_key', $key );
     39
     40                if ( empty( $wp_hasher ) ) {
     41                        require_once ABSPATH . WPINC . '/class-phpass.php';
     42                        $wp_hasher = new PasswordHash( 8, true );
     43                }
     44
     45                $hashed = $wp_hasher->HashPassword( $key );
     46
     47                update_option(
     48                        'recovery_key',
     49                        array(
     50                                'hashed_key' => $hashed,
     51                                'created_at' => time(),
     52                        )
     53                );
     54
     55                return $key;
     56        }
     57
     58        /**
     59         * Verifies if the recovery mode key is correct.
     60         *
     61         * @since 5.2.0
     62         *
     63         * @param string $key The unhashed key.
     64         * @param int    $ttl Time in seconds for the key to be valid for.
     65         * @return true|WP_Error True on success, error object on failure.
     66         */
     67        public function validate_recovery_mode_key( $key, $ttl ) {
     68
     69                $record = get_option( 'recovery_key' );
     70
     71                if ( ! $record ) {
     72                        return new WP_Error( 'no_recovery_key_set', __( 'Recovery Mode not initialized.' ) );
     73                }
     74
     75                if ( ! is_array( $record ) || ! isset( $record['hashed_key'], $record['created_at'] ) ) {
     76                        return new WP_Error( 'invalid_recovery_key_format', __( 'Invalid recovery key format.' ) );
     77                }
     78
     79                if ( ! wp_check_password( $key, $record['hashed_key'] ) ) {
     80                        return new WP_Error( 'hash_mismatch', __( 'Invalid recovery key.' ) );
     81                }
     82
     83                if ( time() > $record['created_at'] + $ttl ) {
     84                        return new WP_Error( 'key_expired', __( 'Recovery key expired.' ) );
     85                }
     86
     87                return true;
     88        }
     89}
  • new file src/wp-includes/class-wp-recovery-mode-link-service.php

    diff --git a/src/wp-includes/class-wp-recovery-mode-link-service.php b/src/wp-includes/class-wp-recovery-mode-link-service.php
    new file mode 100644
    index 0000000000..0ddbfec9d6
    - +  
     1<?php
     2/**
     3 * Error Protection API: WP_Recovery_Mode_Link_Handler class
     4 *
     5 * @package WordPress
     6 * @since   5.2.0
     7 */
     8
     9/**
     10 * Core class used to generate and handle recovery mode links.
     11 *
     12 * @since 5.2.0
     13 */
     14class WP_Recovery_Mode_Link_Service {
     15        const LOGIN_ACTION_ENTER   = 'enter_recovery_mode';
     16        const LOGIN_ACTION_ENTERED = 'entered_recovery_mode';
     17
     18        /**
     19         * Service to generate and validate recovery mode keys.
     20         *
     21         * @since 5.2.0
     22         * @var WP_Recovery_Mode_Key_Service
     23         */
     24        private $key_service;
     25
     26        /**
     27         * Service to handle cookies.
     28         *
     29         * @since 5.2.0
     30         * @var WP_Recovery_Mode_Cookie_Service
     31         */
     32        private $cookie_service;
     33
     34        /**
     35         * WP_Recovery_Mode_Link_Service constructor.
     36         *
     37         * @since 5.2.0
     38         *
     39         * @param WP_Recovery_Mode_Cookie_Service $cookie_service Service to handle setting the recovery mode cookie.
     40         */
     41        public function __construct( WP_Recovery_Mode_Cookie_Service $cookie_service ) {
     42                $this->cookie_service = $cookie_service;
     43                $this->key_service    = new WP_Recovery_Mode_Key_Service();
     44        }
     45
     46        /**
     47         * Generates a URL to begin recovery mode.
     48         *
     49         * Only one recovery mode URL can may be valid at the same time.
     50         *
     51         * @since 5.2.0
     52         *
     53         * @return string Generated URL.
     54         */
     55        public function generate_url() {
     56                $key = $this->key_service->generate_and_store_recovery_mode_key();
     57
     58                return $this->get_recovery_mode_begin_url( $key );
     59        }
     60
     61        /**
     62         * Enters recovery mode when the user hits wp-login.php with a valid recovery mode link.
     63         *
     64         * @since 5.2.0
     65         *
     66         * @param int $ttl Number of seconds the link should be valid for.
     67         */
     68        public function handle_begin_link( $ttl ) {
     69                if ( ! isset( $GLOBALS['pagenow'] ) || 'wp-login.php' !== $GLOBALS['pagenow'] ) {
     70                        return;
     71                }
     72
     73                if ( ! isset( $_GET['action'], $_GET['rm_key'] ) || self::LOGIN_ACTION_ENTER !== $_GET['action'] ) {
     74                        return;
     75                }
     76
     77                if ( ! function_exists( 'wp_generate_password' ) ) {
     78                        require_once ABSPATH . WPINC . '/pluggable.php';
     79                }
     80
     81                $validated = $this->key_service->validate_recovery_mode_key( $_GET['rm_key'], $ttl );
     82
     83                if ( is_wp_error( $validated ) ) {
     84                        wp_die( $validated, '' );
     85                }
     86
     87                $this->cookie_service->set_cookie();
     88
     89                $url = add_query_arg( 'action', self::LOGIN_ACTION_ENTERED, wp_login_url() );
     90                wp_redirect( $url );
     91                die;
     92        }
     93
     94        /**
     95         * Gets a URL to begin recovery mode.
     96         *
     97         * @since 5.2.0
     98         *
     99         * @param string $key Recovery Mode key created by {@see generate_and_store_recovery_mode_key()}
     100         * @return string Recovery mode begin URL.
     101         */
     102        private function get_recovery_mode_begin_url( $key ) {
     103
     104                $url = add_query_arg(
     105                        array(
     106                                'action' => self::LOGIN_ACTION_ENTER,
     107                                'rm_key' => $key,
     108                        ),
     109                        wp_login_url()
     110                );
     111
     112                /**
     113                 * Filter the URL to begin recovery mode.
     114                 *
     115                 * @since 5.2.0
     116                 *
     117                 * @param string $url
     118                 * @param string $key
     119                 */
     120                return apply_filters( 'recovery_mode_begin_url', $url, $key );
     121        }
     122}
  • new file src/wp-includes/class-wp-recovery-mode.php

    diff --git a/src/wp-includes/class-wp-recovery-mode.php b/src/wp-includes/class-wp-recovery-mode.php
    new file mode 100644
    index 0000000000..cdbc290ec2
    - +  
     1<?php
     2/**
     3 * Error Protection API: WP_Recovery_Mode class
     4 *
     5 * @package WordPress
     6 * @since   5.2.0
     7 */
     8
     9/**
     10 * Core class used to implement Recovery Mode.
     11 *
     12 * @since 5.2.0
     13 */
     14class WP_Recovery_Mode {
     15
     16        const EXIT_ACTION = 'exit_recovery_mode';
     17
     18        /**
     19         * Service to handle sending an email with a recovery mode link.
     20         *
     21         * @since 5.2.0
     22         * @var WP_Recovery_Mode_Email_Service
     23         */
     24        private $email_service;
     25
     26        /**
     27         * Service to generate and validate recovery mode links.
     28         *
     29         * @since 5.2.0
     30         * @var WP_Recovery_Mode_Link_Service
     31         */
     32        private $link_service;
     33
     34        /**
     35         * Service to handle cookies.
     36         *
     37         * @since 5.2.0
     38         * @var WP_Recovery_Mode_Cookie_Service
     39         */
     40        private $cookie_service;
     41
     42        /**
     43         * Is recovery mode initialized.
     44         *
     45         * @since 5.2.0
     46         * @var bool
     47         */
     48        private $is_initialized = false;
     49
     50        /**
     51         * Is recovery mode active in this session.
     52         *
     53         * @since 5.2.0
     54         * @var bool
     55         */
     56        private $is_active = false;
     57
     58        /**
     59         * Get an ID representing the current recovery mode session.
     60         *
     61         * @since 5.2.0
     62         * @var string
     63         */
     64        private $session_id = '';
     65
     66        /**
     67         * WP_Recovery_Mode constructor.
     68         *
     69         * @since 5.2.0
     70         */
     71        public function __construct() {
     72                $this->cookie_service = new WP_Recovery_Mode_Cookie_Service();
     73                $this->link_service   = new WP_Recovery_Mode_Link_Service( $this->cookie_service );
     74                $this->email_service  = new WP_Recovery_Mode_Email_Service( $this->link_service );
     75        }
     76
     77        /**
     78         * Initialize recovery mode for the current request.
     79         *
     80         * @since 5.2.0
     81         */
     82        public function initialize() {
     83                $this->is_initialized = true;
     84
     85                add_action( 'login_form_' . self::EXIT_ACTION, array( $this, 'handle_exit_recovery_mode' ) );
     86
     87                if ( defined( 'WP_RECOVERY_MODE_SESSION_ID' ) ) {
     88                        $this->is_active  = true;
     89                        $this->session_id = WP_RECOVERY_MODE_SESSION_ID;
     90
     91                        return;
     92                }
     93
     94                if ( $this->cookie_service->is_cookie_set() ) {
     95                        $this->handle_cookie();
     96
     97                        return;
     98                }
     99
     100                $this->link_service->handle_begin_link( $this->get_link_ttl() );
     101        }
     102
     103        /**
     104         * Checks whether recovery mode is active.
     105         *
     106         * This will not change after recovery mode has been initialized. {@see WP_Recovery_Mode::run()}.
     107         *
     108         * @since 5.2.0
     109         *
     110         * @return bool True if recovery mode is active, false otherwise.
     111         */
     112        public function is_active() {
     113                return $this->is_active;
     114        }
     115
     116        /**
     117         * Gets the recovery mode session ID.
     118         *
     119         * @since 5.2.0
     120         *
     121         * @return string The session ID if recovery mode is active, empty string otherwise.
     122         */
     123        public function get_session_id() {
     124                return $this->session_id;
     125        }
     126
     127        /**
     128         * Checks whether recovery mode has been initialized.
     129         *
     130         * Recovery mode should not be used until this point. Initialization happens immediately before loading plugins.
     131         *
     132         * @since 5.2.0
     133         *
     134         * @return bool
     135         */
     136        public function is_initialized() {
     137                return $this->is_initialized;
     138        }
     139
     140        /**
     141         * Handles a fatal error occurring.
     142         *
     143         * The calling API should immediately die() after calling this function.
     144         *
     145         * @since 5.2.0
     146         *
     147         * @param array $error Error details from {@see error_get_last()}
     148         * @return true|WP_Error True if the error was handled and headers have already been sent.
     149         *                       Or the request will exit to try and catch multiple errors at once.
     150         *                       WP_Error if an error occurred preventing it from being handled.
     151         */
     152        public function handle_error( array $error ) {
     153
     154                $extension = $this->get_extension_for_error( $error );
     155
     156                if ( ! $extension || $this->is_network_plugin( $extension ) ) {
     157                        return new WP_Error( 'invalid_source', __( 'Error not caused by a plugin or theme.' ) );
     158                }
     159
     160                if ( ! $this->is_active() ) {
     161                        if ( ! function_exists( 'wp_generate_password' ) ) {
     162                                require_once ABSPATH . WPINC . '/pluggable.php';
     163                        }
     164
     165                        return $this->email_service->maybe_send_recovery_mode_email( $this->get_email_rate_limit(), $error, $extension );
     166                }
     167
     168                if ( ! $this->store_error( $error ) ) {
     169                        return new WP_Error( 'storage_error', __( 'Failed to store the error.' ) );
     170                }
     171
     172                if ( headers_sent() ) {
     173                        return true;
     174                }
     175
     176                $this->redirect_protected();
     177        }
     178
     179        /**
     180         * Ends the current recovery mode session.
     181         *
     182         * @since 5.2.0
     183         *
     184         * @return bool True on success, false on failure.
     185         */
     186        public function exit_recovery_mode() {
     187                if ( ! $this->is_active() ) {
     188                        return false;
     189                }
     190
     191                $this->email_service->clear_rate_limit();
     192                $this->cookie_service->clear_cookie();
     193
     194                wp_paused_plugins()->delete_all();
     195                wp_paused_themes()->delete_all();
     196
     197                return true;
     198        }
     199
     200        /**
     201         * Handles a request to exit Recovery Mode.
     202         *
     203         * @since 5.2.0
     204         */
     205        public function handle_exit_recovery_mode() {
     206                $redirect_to = wp_get_referer();
     207
     208                // Safety check in case referrer returns false.
     209                if ( ! $redirect_to ) {
     210                        $redirect_to = is_user_logged_in() ? admin_url() : home_url();
     211                }
     212
     213                if ( ! $this->is_active() ) {
     214                        wp_safe_redirect( $redirect_to );
     215                        die;
     216                }
     217
     218                if ( ! isset( $_GET['action'] ) || self::EXIT_ACTION !== $_GET['action'] ) {
     219                        return;
     220                }
     221
     222                if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( $_GET['_wpnonce'], self::EXIT_ACTION ) ) {
     223                        wp_die( __( 'Exit recovery mode link expired.' ) );
     224                }
     225
     226                if ( ! $this->exit_recovery_mode() ) {
     227                        wp_die( __( 'Failed to exit recovery mode. Please try again later.' ) );
     228                }
     229
     230                wp_safe_redirect( $redirect_to );
     231                die;
     232        }
     233
     234        /**
     235         * Handles checking for the recovery mode cookie and validating it.
     236         *
     237         * @since 5.2.0
     238         */
     239        protected function handle_cookie() {
     240                $validated = $this->cookie_service->validate_cookie();
     241
     242                if ( is_wp_error( $validated ) ) {
     243                        $this->cookie_service->clear_cookie();
     244
     245                        wp_die( $validated, '' );
     246                }
     247
     248                $session_id = $this->cookie_service->get_session_id_from_cookie();
     249                if ( is_wp_error( $session_id ) ) {
     250                        $this->cookie_service->clear_cookie();
     251
     252                        wp_die( $session_id, '' );
     253                }
     254
     255                $this->is_active  = true;
     256                $this->session_id = $session_id;
     257        }
     258
     259        /**
     260         * Gets the rate limit between sending new recovery mode email links.
     261         *
     262         * @since 5.2.0
     263         *
     264         * @return int Rate limit in seconds.
     265         */
     266        protected function get_email_rate_limit() {
     267                /**
     268                 * Filter the rate limit between sending new recovery mode email links.
     269                 *
     270                 * @since 5.2.0
     271                 *
     272                 * @param int $rate_limit Time to wait in seconds. Defaults to 4 hours.
     273                 */
     274                return apply_filters( 'recovery_mode_email_rate_limit', 4 * HOUR_IN_SECONDS );
     275        }
     276
     277        /**
     278         * Gets the number of seconds the recovery mode link is valid for.
     279         *
     280         * @since 5.2.0
     281         *
     282         * @return int Interval in seconds.
     283         */
     284        protected function get_link_ttl() {
     285
     286                $rate_limit = $this->get_email_rate_limit();
     287                $valid_for  = $rate_limit;
     288
     289                /**
     290                 * Filter the amount of time the recovery mode email link is valid for.
     291                 *
     292                 * The ttl must be at least as long as the email rate limit.
     293                 *
     294                 * @since 5.2.0
     295                 *
     296                 * @param int $valid_for The number of seconds the link is valid for.
     297                 */
     298                $valid_for = apply_filters( 'recovery_mode_email_link_ttl', $valid_for );
     299
     300                return max( $valid_for, $rate_limit );
     301        }
     302
     303        /**
     304         * Gets the extension that the error occurred in.
     305         *
     306         * @since 5.2.0
     307         *
     308         * @global array $wp_theme_directories
     309         *
     310         * @param array  $error Error that was triggered.
     311         *
     312         * @return array|false {
     313         *      @type string  $slug  The extension slug. This is the plugin or theme's directory.
     314         *      @type string  $type  The extension type. Either 'plugin' or 'theme'.
     315         * }
     316         */
     317        protected function get_extension_for_error( $error ) {
     318                global $wp_theme_directories;
     319
     320                if ( ! isset( $error['file'] ) ) {
     321                        return false;
     322                }
     323
     324                if ( ! defined( 'WP_PLUGIN_DIR' ) ) {
     325                        return false;
     326                }
     327
     328                $error_file    = wp_normalize_path( $error['file'] );
     329                $wp_plugin_dir = wp_normalize_path( WP_PLUGIN_DIR );
     330
     331                if ( 0 === strpos( $error_file, $wp_plugin_dir ) ) {
     332                        $path  = str_replace( $wp_plugin_dir . '/', '', $error_file );
     333                        $parts = explode( '/', $path );
     334
     335                        return array(
     336                                'type' => 'plugin',
     337                                'slug' => $parts[0],
     338                        );
     339                }
     340
     341                if ( empty( $wp_theme_directories ) ) {
     342                        return false;
     343                }
     344
     345                foreach ( $wp_theme_directories as $theme_directory ) {
     346                        $theme_directory = wp_normalize_path( $theme_directory );
     347
     348                        if ( 0 === strpos( $error_file, $theme_directory ) ) {
     349                                $path  = str_replace( $theme_directory . '/', '', $error_file );
     350                                $parts = explode( '/', $path );
     351
     352                                return array(
     353                                        'type' => 'theme',
     354                                        'slug' => $parts[0],
     355                                );
     356                        }
     357                }
     358
     359                return false;
     360        }
     361
     362        /**
     363         * Checks whether the given extension a network activated plugin.
     364         *
     365         * @since 5.2.0
     366         *
     367         * @param array $extension Extension data.
     368         * @return bool True if network plugin, false otherwise.
     369         */
     370        protected function is_network_plugin( $extension ) {
     371                if ( 'plugin' !== $extension['type'] ) {
     372                        return false;
     373                }
     374
     375                if ( ! is_multisite() ) {
     376                        return false;
     377                }
     378
     379                $network_plugins = wp_get_active_network_plugins();
     380
     381                foreach ( $network_plugins as $plugin ) {
     382                        if ( 0 === strpos( $plugin, $extension['slug'] . '/' ) ) {
     383                                return true;
     384                        }
     385                }
     386
     387                return false;
     388        }
     389
     390        /**
     391         * Stores the given error so that the extension causing it is paused.
     392         *
     393         * @since 5.2.0
     394         *
     395         * @param array $error Error that was triggered.
     396         * @return bool True if the error was stored successfully, false otherwise.
     397         */
     398        protected function store_error( $error ) {
     399                $extension = $this->get_extension_for_error( $error );
     400
     401                if ( ! $extension ) {
     402                        return false;
     403                }
     404
     405                switch ( $extension['type'] ) {
     406                        case 'plugin':
     407                                return wp_paused_plugins()->set( $extension['slug'], $error );
     408                        case 'theme':
     409                                return wp_paused_themes()->set( $extension['slug'], $error );
     410                        default:
     411                                return false;
     412                }
     413        }
     414
     415        /**
     416         * Redirects the current request to allow recovering multiple errors in one go.
     417         *
     418         * The redirection will only happen when on a protected endpoint.
     419         *
     420         * It must be ensured that this method is only called when an error actually occurred and will not occur on the
     421         * next request again. Otherwise it will create a redirect loop.
     422         *
     423         * @since 5.2.0
     424         */
     425        protected function redirect_protected() {
     426                // Pluggable is usually loaded after plugins, so we manually include it here for redirection functionality.
     427                if ( ! function_exists( 'wp_safe_redirect' ) ) {
     428                        require_once ABSPATH . WPINC . '/pluggable.php';
     429                }
     430
     431                $scheme = is_ssl() ? 'https://' : 'http://';
     432
     433                $url = "{$scheme}{$_SERVER['HTTP_HOST']}{$_SERVER['REQUEST_URI']}";
     434                wp_safe_redirect( $url );
     435                exit;
     436        }
     437}
  • 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..0bdd6a43e3 100644
    a b public function __construct( $theme_dir, $theme_root, $_child = null ) { 
    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_themes()->get( $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 4ab87de558..ecdc87cb3a 100644
    a b  
    579579
    580580// Capabilities
    581581add_filter( 'user_has_cap', 'wp_maybe_grant_install_languages_cap', 1 );
     582add_filter( 'user_has_cap', 'wp_maybe_grant_resume_extensions_caps', 1 );
    582583
    583584unset( $filter, $action );
  • src/wp-includes/error-protection.php

    diff --git a/src/wp-includes/error-protection.php b/src/wp-includes/error-protection.php
    index abcb1f9ba7..92bef74aa7 100644
    a b  
    66 * @since   5.2.0
    77 */
    88
     9/**
     10 * Get the instance for storing paused plugins.
     11 *
     12 * @return WP_Paused_Extensions_Storage
     13 */
     14function wp_paused_plugins() {
     15        static $storage = null;
     16
     17        if ( null === $storage ) {
     18                $storage = new WP_Paused_Extensions_Storage( 'plugin' );
     19        }
     20
     21        return $storage;
     22}
     23
     24/**
     25 * Get the instance for storing paused extensions.
     26 *
     27 * @return WP_Paused_Extensions_Storage
     28 */
     29function wp_paused_themes() {
     30        static $storage = null;
     31
     32        if ( null === $storage ) {
     33                $storage = new WP_Paused_Extensions_Storage( 'theme' );
     34        }
     35
     36        return $storage;
     37}
     38
     39/**
     40 * Get a human readable description of an extension's error.
     41 *
     42 * @since 5.2.0
     43 *
     44 * @param array $error Error details {@see error_get_last()}
     45 *
     46 * @return string Formatted error description.
     47 */
     48function wp_get_extension_error_description( $error ) {
     49        $constants   = get_defined_constants( true );
     50        $constants   = isset( $constants['Core'] ) ? $constants['Core'] : $constants['internal'];
     51        $core_errors = array();
     52
     53        foreach ( $constants as $constant => $value ) {
     54                if ( 0 === strpos( $constant, 'E_' ) ) {
     55                        $core_errors[ $value ] = $constant;
     56                }
     57        }
     58
     59        if ( isset( $core_errors[ $error['type'] ] ) ) {
     60                $error['type'] = $core_errors[ $error['type'] ];
     61        }
     62
     63        /* translators: 1: error type, 2: error line number, 3: error file name, 4: error message */
     64        $error_message = __( 'An error of type %1$s was caused in line %2$s of the file %3$s. Error message: %4$s' );
     65
     66        return sprintf(
     67                $error_message,
     68                "<code>{$error['type']}</code>",
     69                "<code>{$error['line']}</code>",
     70                "<code>{$error['file']}</code>",
     71                "<code>{$error['message']}</code>"
     72        );
     73}
     74
    975/**
    1076 * Registers the shutdown handler for fatal errors.
    1177 *
    function wp_is_fatal_error_handler_enabled() { 
    52118         */
    53119        return apply_filters( 'wp_fatal_error_handler_enabled', $enabled );
    54120}
     121
     122/**
     123 * Access the WordPress Recovery Mode instance.
     124 *
     125 * @since 5.2.0
     126 *
     127 * @return WP_Recovery_Mode
     128 */
     129function wp_recovery_mode() {
     130        static $wp_recovery_mode;
     131
     132        if ( ! $wp_recovery_mode ) {
     133                $wp_recovery_mode = new WP_Recovery_Mode();
     134        }
     135
     136        return $wp_recovery_mode;
     137}
  • src/wp-includes/load.php

    diff --git a/src/wp-includes/load.php b/src/wp-includes/load.php
    index 245ecb1e61..b3c3762684 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_plugins()->get_all();
     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_themes()->get_all();
     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_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 1110e0210f..10eb813a7a 100644
    a b function retrieve_password() { 
    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_Link_Service::LOGIN_ACTION_ENTERED ), true ) && false === has_filter( 'login_form_' . $action ) ) {
    443443        $action = 'login';
    444444}
    445445
    function retrieve_password() { 
    10281028                                $errors->add( 'registered', __( 'Registration complete. Please check your email.' ), 'message' );
    10291029                        } elseif ( strpos( $redirect_to, 'about.php?updated' ) ) {
    10301030                                $errors->add( 'updated', __( '<strong>You have successfully updated WordPress!</strong> Please log back in to see what&#8217;s new.' ), 'message' );
     1031                        } elseif ( WP_Recovery_Mode_Link_Service::LOGIN_ACTION_ENTERED === $action ) {
     1032                                $errors->add( 'enter_recovery_mode', __( 'Recovery Mode Initialized. Please log in to continue.' ), 'message' );
    10311033                        }
    10321034                }
    10331035
  • src/wp-settings.php

    diff --git a/src/wp-settings.php b/src/wp-settings.php
    index 5f52637409..fa9f9a3a25 100644
    a b  
    1717
    1818// Include files required for initialization.
    1919require( ABSPATH . WPINC . '/load.php' );
     20require( ABSPATH . WPINC . '/class-wp-paused-extensions-storage.php' );
    2021require( ABSPATH . WPINC . '/class-wp-fatal-error-handler.php' );
     22require( ABSPATH . WPINC . '/class-wp-recovery-mode-cookie-service.php' );
     23require( ABSPATH . WPINC . '/class-wp-recovery-mode-key-service.php' );
     24require( ABSPATH . WPINC . '/class-wp-recovery-mode-link-service.php' );
     25require( ABSPATH . WPINC . '/class-wp-recovery-mode-email-service.php' );
     26require( ABSPATH . WPINC . '/class-wp-recovery-mode.php' );
    2127require( ABSPATH . WPINC . '/error-protection.php' );
    2228require( ABSPATH . WPINC . '/default-constants.php' );
    2329require_once( ABSPATH . WPINC . '/plugin.php' );
     
    345351// Register the default theme directory root
    346352register_theme_directory( get_theme_root() );
    347353
     354if ( ! is_multisite() ) {
     355        // Handle users requesting a recovery mode link and initiating recovery mode.
     356        wp_recovery_mode()->initialize();
     357}
     358
    348359// Load active plugins.
    349360foreach ( wp_get_active_and_valid_plugins() as $plugin ) {
    350361        wp_register_plugin_realpath( $plugin );
  • new file tests/phpunit/tests/error-protection/recovery-mode-cookie-service.php

    diff --git a/tests/phpunit/tests/error-protection/recovery-mode-cookie-service.php b/tests/phpunit/tests/error-protection/recovery-mode-cookie-service.php
    new file mode 100644
    index 0000000000..203b1f9aee
    - +  
     1<?php
     2
     3/**
     4 * @group error-protection
     5 */
     6class Tests_Recovery_Mode_Cookie_Service extends WP_UnitTestCase {
     7
     8        /**
     9         * @ticket 46130
     10         */
     11        public function test_validate_cookie_returns_wp_error_if_invalid_format() {
     12
     13                $service = new WP_Recovery_Mode_Cookie_Service();
     14
     15                $error = $service->validate_cookie( 'gibbersih' );
     16                $this->assertWPError( $error );
     17                $this->assertEquals( 'invalid_format', $error->get_error_code() );
     18
     19                $error = $service->validate_cookie( base64_encode( 'test|data|format' ) );
     20                $this->assertWPError( $error );
     21                $this->assertEquals( 'invalid_format', $error->get_error_code() );
     22
     23                $error = $service->validate_cookie( base64_encode( 'test|data|format|to|long' ) );
     24                $this->assertWPError( $error );
     25                $this->assertEquals( 'invalid_format', $error->get_error_code() );
     26        }
     27
     28        /**
     29         * @ticket 46130
     30         */
     31        public function test_validate_cookie_returns_wp_error_if_expired() {
     32                $service    = new WP_Recovery_Mode_Cookie_Service();
     33                $reflection = new ReflectionMethod( $service, 'recovery_mode_hash' );
     34                $reflection->setAccessible( true );
     35
     36                $to_sign = sprintf( 'recovery_mode|%s|%s', time() - WEEK_IN_SECONDS - 30, wp_generate_password( 20, false ) );
     37                $signed  = $reflection->invoke( $service, $to_sign );
     38                $cookie  = base64_encode( sprintf( '%s|%s', $to_sign, $signed ) );
     39
     40                $error = $service->validate_cookie( $cookie );
     41                $this->assertWPError( $error );
     42                $this->assertEquals( 'expired', $error->get_error_code() );
     43        }
     44
     45        /**
     46         * @ticket 46130
     47         */
     48        public function test_validate_cookie_returns_wp_error_if_signature_mismatch() {
     49                $service    = new WP_Recovery_Mode_Cookie_Service();
     50                $reflection = new ReflectionMethod( $service, 'generate_cookie' );
     51                $reflection->setAccessible( true );
     52
     53                $cookie = $reflection->invoke( $service );
     54                $cookie .= 'gibbersih';
     55
     56                $error = $service->validate_cookie( $cookie );
     57                $this->assertWPError( $error );
     58                $this->assertEquals( 'signature_mismatch', $error->get_error_code() );
     59        }
     60
     61        /**
     62         * @ticket 46130
     63         */
     64        public function test_validate_cookie_returns_wp_error_if_created_at_is_invalid_format() {
     65                $service    = new WP_Recovery_Mode_Cookie_Service();
     66                $reflection = new ReflectionMethod( $service, 'recovery_mode_hash' );
     67                $reflection->setAccessible( true );
     68
     69                $to_sign = sprintf( 'recovery_mode|%s|%s', 'month', wp_generate_password( 20, false ) );
     70                $signed  = $reflection->invoke( $service, $to_sign );
     71                $cookie  = base64_encode( sprintf( '%s|%s', $to_sign, $signed ) );
     72
     73                $error = $service->validate_cookie( $cookie );
     74                $this->assertWPError( $error );
     75                $this->assertEquals( 'invalid_created_at', $error->get_error_code() );
     76        }
     77
     78        /**
     79         * @ticket 46130
     80         */
     81        public function test_validate_cookie_returns_true_for_valid_cookie() {
     82
     83                $service    = new WP_Recovery_Mode_Cookie_Service();
     84                $reflection = new ReflectionMethod( $service, 'generate_cookie' );
     85                $reflection->setAccessible( true );
     86
     87                $this->assertTrue( $service->validate_cookie( $reflection->invoke( $service ) ) );
     88        }
     89}
  • new file tests/phpunit/tests/error-protection/recovery-mode-key-service.php

    diff --git a/tests/phpunit/tests/error-protection/recovery-mode-key-service.php b/tests/phpunit/tests/error-protection/recovery-mode-key-service.php
    new file mode 100644
    index 0000000000..8e275c3535
    - +  
     1<?php
     2
     3/**
     4 * @group error-protection
     5 */
     6class Tests_Recovery_Mode_Key_Service extends WP_UnitTestCase {
     7
     8        /**
     9         * @ticket 46130
     10         */
     11        public function test_generate_and_store_recovery_mode_key_returns_recovery_key() {
     12                $service = new WP_Recovery_Mode_Key_Service();
     13                $key     = $service->generate_and_store_recovery_mode_key();
     14
     15                $this->assertNotWPError( $key );
     16        }
     17
     18        /**
     19         * @ticket 46130
     20         */
     21        public function test_validate_recovery_mode_key_returns_wp_error_if_no_key_set() {
     22                $service = new WP_Recovery_Mode_Key_Service();
     23                $error   = $service->validate_recovery_mode_key( 'abcd', HOUR_IN_SECONDS );
     24
     25                $this->assertWPError( $error );
     26                $this->assertEquals( 'no_recovery_key_set', $error->get_error_code() );
     27        }
     28
     29        /**
     30         * @ticket 46130
     31         */
     32        public function test_validate_recovery_mode_key_returns_wp_error_if_stored_format_is_invalid() {
     33                update_option( 'recovery_key', 'gibberish' );
     34
     35                $service = new WP_Recovery_Mode_Key_Service();
     36                $error   = $service->validate_recovery_mode_key( 'abcd', HOUR_IN_SECONDS );
     37
     38                $this->assertWPError( $error );
     39                $this->assertEquals( 'invalid_recovery_key_format', $error->get_error_code() );
     40        }
     41
     42        /**
     43         * @ticket 46130
     44         */
     45        public function test_validate_recovery_mode_key_returns_wp_error_if_empty_key() {
     46                $service = new WP_Recovery_Mode_Key_Service();
     47                $service->generate_and_store_recovery_mode_key();
     48                $error = $service->validate_recovery_mode_key( '', HOUR_IN_SECONDS );
     49
     50                $this->assertWPError( $error );
     51                $this->assertEquals( 'hash_mismatch', $error->get_error_code() );
     52        }
     53
     54        /**
     55         * @ticket 46130
     56         */
     57        public function test_validate_recovery_mode_key_returns_wp_error_if_hash_mismatch() {
     58                $service = new WP_Recovery_Mode_Key_Service();
     59                $service->generate_and_store_recovery_mode_key();
     60                $error = $service->validate_recovery_mode_key( 'abcd', HOUR_IN_SECONDS );
     61
     62                $this->assertWPError( $error );
     63                $this->assertEquals( 'hash_mismatch', $error->get_error_code() );
     64        }
     65
     66        /**
     67         * @ticket 46130
     68         */
     69        public function test_validate_recovery_mode_key_returns_wp_error_if_expired() {
     70                $service = new WP_Recovery_Mode_Key_Service();
     71                $key     = $service->generate_and_store_recovery_mode_key();
     72
     73                $record               = get_option( 'recovery_key' );
     74                $record['created_at'] = time() - HOUR_IN_SECONDS - 30;
     75                update_option( 'recovery_key', $record );
     76
     77                $error = $service->validate_recovery_mode_key( $key, HOUR_IN_SECONDS );
     78
     79                $this->assertWPError( $error );
     80                $this->assertEquals( 'key_expired', $error->get_error_code() );
     81        }
     82
     83        /**
     84         * @ticket 46130
     85         */
     86        public function test_validate_recovery_mode_key_returns_true_for_valid_key() {
     87                $service = new WP_Recovery_Mode_Key_Service();
     88                $key     = $service->generate_and_store_recovery_mode_key();
     89                $this->assertTrue( $service->validate_recovery_mode_key( $key, HOUR_IN_SECONDS ) );
     90        }
     91}
  • tests/phpunit/tests/user/capabilities.php

    diff --git a/tests/phpunit/tests/user/capabilities.php b/tests/phpunit/tests/user/capabilities.php
    index 3bc5264d52..86d70c3ea2 100644
    a b function _meta_filter( $meta_value, $meta_key, $meta_type ) { 
    101101                        'remove_users'           => array( 'administrator' ),
    102102                        'switch_themes'          => array( 'administrator' ),
    103103                        'edit_dashboard'         => array( 'administrator' ),
     104                        'resume_plugins'         => array( 'administrator' ),
     105                        'resume_themes'          => array( 'administrator' ),
    104106
    105107                        'moderate_comments'      => array( 'administrator', 'editor' ),
    106108                        'manage_categories'      => array( 'administrator', 'editor' ),
    function _meta_filter( $meta_value, $meta_key, $meta_type ) { 
    181183                        'remove_users'           => array( 'administrator' ),
    182184                        'switch_themes'          => array( 'administrator' ),
    183185                        'edit_dashboard'         => array( 'administrator' ),
     186                        'resume_plugins'         => array( 'administrator' ),
     187                        'resume_themes'          => array( 'administrator' ),
    184188
    185189                        'moderate_comments'      => array( 'administrator', 'editor' ),
    186190                        'manage_categories'      => array( 'administrator', 'editor' ),
    public function testPrimitiveCapsTestsAreCorrect() { 
    392396                        $actual['editor'],
    393397                        $actual['author'],
    394398                        $actual['subscriber'],
    395                         $actual['contributor']
     399                        $actual['contributor'],
     400                        // the following two are granted via `user_has_cap`:
     401                        $actual['resume_plugins'],
     402                        $actual['resume_themes']
    396403                );
    397404
    398405                unset(
    public function testMetaCapsTestsAreCorrect() { 
    454461                        // Singular object meta capabilities (where an object ID is passed) are not tested:
    455462                        $expected['activate_plugin'],
    456463                        $expected['deactivate_plugin'],
     464                        $expected['resume_plugin'],
     465                        $expected['resume_theme'],
    457466                        $expected['remove_user'],
    458467                        $expected['promote_user'],
    459468                        $expected['edit_user'],