Make WordPress Core

Changeset 44973


Ignore:
Timestamp:
03/21/2019 09:52:07 PM (5 years ago)
Author:
flixos90
Message:

Bootstrap/Load: Introduce a recovery mode for fixing fatal errors.

Using the new fatal handler introduced in [44962], an email is sent to the admin when a fatal error occurs. This email includes a secret link to enter recovery mode. When clicked, the link will be validated and on success a cookie will be placed on the client, enabling recovery mode for that user. This functionality is executed early before plugins and themes are loaded, in order to be unaffected by potential fatal errors these might be causing.

When in recovery mode, broken plugins and themes will be paused for that client, so that they are able to access the admin backend despite of these errors. They are notified about the broken extensions and the errors caused, and can then decide whether they would like to temporarily deactivate the extension or fix the problem and resume the extension.

A link in the admin bar allows the client to exit recovery mode.

Props timothyblynjacobs, afragen, flixos90, nerrad, miss_jwo, schlessera, spacedmonkey, swissspidy.
Fixes #46130, #44458.

Location:
trunk
Files:
9 added
19 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-admin/css/list-tables.css

    r44791 r44973  
    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;
  • trunk/src/wp-admin/includes/admin-filters.php

    r44717 r44973  
    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
  • trunk/src/wp-admin/includes/class-wp-plugins-list-table.php

    r44937 r44973  
    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        }
     
    100100            'mustuse'            => array(),
    101101            'dropins'            => array(),
     102            'paused'             => array(),
    102103        );
    103104
     
    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            }
     
    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 ] ) ) {
     
    445452                    /* translators: %s: plugin count */
    446453                    $text = _n( 'Drop-ins <span class="count">(%s)</span>', 'Drop-ins <span class="count">(%s)</span>', $count );
     454                    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 );
    447458                    break;
    448459                case 'upgrade':
     
    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 ) ) {
     
    764779        if ( ! empty( $totals['upgrade'] ) && ! empty( $plugin_data['update'] ) ) {
    765780            $class .= ' update';
     781        }
     782
     783        $paused = ! $screen->in_admin( 'network' ) && is_plugin_paused( $plugin_file );
     784
     785        if ( $paused ) {
     786            $class .= ' paused';
    766787        }
    767788
     
    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 );
     
    853874
    854875                    echo '</div>';
     876
     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                    }
    855888
    856889                    echo '</td>';
     
    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 );
     
    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 );
  • trunk/src/wp-admin/includes/plugin.php

    r44717 r44973  
    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
     
    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}
  • trunk/src/wp-admin/includes/theme.php

    r44717 r44973  
    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}
  • trunk/src/wp-admin/plugins.php

    r44717 r44973  
    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'] ) ) {
     
    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>.' );
     
    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
  • trunk/src/wp-admin/themes.php

    r44717 r44973  
    3333        switch_theme( $theme->get_stylesheet() );
    3434        wp_redirect( admin_url( 'themes.php?activated=true' ) );
     35        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' ) );
    3555        exit;
    3656    } elseif ( 'delete' == $_GET['action'] ) {
     
    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
     
    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' );
     
    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>
     
    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();
  • trunk/src/wp-includes/admin-bar.php

    r44924 r44973  
    10491049
    10501050/**
     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
     1079/**
    10511080 * Add secondary menus.
    10521081 *
  • trunk/src/wp-includes/capabilities.php

    r44717 r44973  
    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':
     
    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}
  • trunk/src/wp-includes/class-wp-admin-bar.php

    r44793 r44973  
    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.
  • trunk/src/wp-includes/class-wp-fatal-error-handler.php

    r44962 r44973  
    3737            if ( ! $error ) {
    3838                return;
     39            }
     40
     41            if ( ! is_multisite() && wp_recovery_mode()->is_initialized() ) {
     42                wp_recovery_mode()->handle_error( $error );
    3943            }
    4044
  • trunk/src/wp-includes/class-wp-theme.php

    r44717 r44973  
    370370            // Set the parent. Pass the current instance so we can do the crazy checks above and assess errors.
    371371            $this->parent = new WP_Theme( $this->template, isset( $theme_root_template ) ? $theme_root_template : $this->theme_root, $this );
     372        }
     373
     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.' ) );
    372376        }
    373377
  • trunk/src/wp-includes/default-constants.php

    r44151 r44973  
    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
  • trunk/src/wp-includes/default-filters.php

    r44942 r44973  
    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 );
  • trunk/src/wp-includes/error-protection.php

    r44962 r44973  
    66 * @since   5.2.0
    77 */
     8
     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}
    874
    975/**
     
    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}
  • trunk/src/wp-includes/load.php

    r44717 r44973  
    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}
     
    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
    728778    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
     807    return $themes;
     808}
     809
     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;
    729903}
    730904
  • trunk/src/wp-login.php

    r44932 r44973  
    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}
     
    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        }
  • trunk/src/wp-settings.php

    r44962 r44973  
    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' );
     
    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 ) {
  • trunk/tests/phpunit/tests/user/capabilities.php

    r44717 r44973  
    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' ),
     
    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' ),
     
    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
     
    455462            $expected['activate_plugin'],
    456463            $expected['deactivate_plugin'],
     464            $expected['resume_plugin'],
     465            $expected['resume_theme'],
    457466            $expected['remove_user'],
    458467            $expected['promote_user'],
Note: See TracChangeset for help on using the changeset viewer.