Ticket #46130: 46130.3.diff
File 46130.3.diff, 83.4 KB (added by , 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 { 1310 1310 text-decoration: underline; 1311 1311 } 1312 1312 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 1313 1338 .plugin-card .update-now:before { 1314 1339 color: #f56e28; 1315 1340 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 117 117 add_action( 'load-themes.php', 'wp_theme_update_rows', 20 ); // After wp_update_themes() is called. 118 118 119 119 add_action( 'admin_notices', 'update_nag', 3 ); 120 add_action( 'admin_notices', 'paused_plugins_notice', 5 ); 121 add_action( 'admin_notices', 'paused_themes_notice', 5 ); 120 122 add_action( 'admin_notices', 'maintenance_nag', 10 ); 121 123 122 124 add_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() ) { 40 40 ); 41 41 42 42 $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' ) ) ) { 44 44 $status = $_REQUEST['plugin_status']; 45 45 } 46 46 … … public function prepare_items() { 99 99 'upgrade' => array(), 100 100 'mustuse' => array(), 101 101 'dropins' => array(), 102 'paused' => array(), 102 103 ); 103 104 104 105 $screen = $this->screen; … … public function prepare_items() { 183 184 foreach ( (array) $plugins['all'] as $plugin_file => $plugin_data ) { 184 185 // Extra info if known. array_merge() ensures $plugin_data has precedence if keys collide. 185 186 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; 187 189 // Make sure that $plugins['upgrade'] also receives the extra info since it is used on ?plugin_status=upgrade 188 190 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; 190 192 } 191 193 } 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; 193 196 // Make sure that $plugins['upgrade'] also receives the extra info since it is used on ?plugin_status=upgrade 194 197 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; 196 199 } 197 200 } 198 201 … … public function prepare_items() { 218 221 // On the non-network screen, populate the active list with plugins that are individually activated 219 222 // On the network-admin screen, populate the active list with plugins that are network activated 220 223 $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 } 221 228 } else { 222 229 if ( isset( $recently_activated[ $plugin_file ] ) ) { 223 230 // Populate the recently activated list with plugins that have been recently activated … … protected function get_views() { 445 452 /* translators: %s: plugin count */ 446 453 $text = _n( 'Drop-ins <span class="count">(%s)</span>', 'Drop-ins <span class="count">(%s)</span>', $count ); 447 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 ); 458 break; 448 459 case 'upgrade': 449 460 /* translators: %s: plugin count */ 450 461 $text = _n( 'Update Available <span class="count">(%s)</span>', 'Update Available <span class="count">(%s)</span>', $count ); … … public function single_row( $item ) { 657 668 /* translators: %s: plugin name */ 658 669 $actions['deactivate'] = '<a href="' . wp_nonce_url( 'plugins.php?action=deactivate&plugin=' . urlencode( $plugin_file ) . '&plugin_status=' . $context . '&paged=' . $page . '&s=' . $s, 'deactivate-plugin_' . $plugin_file ) . '" aria-label="' . esc_attr( sprintf( _x( 'Deactivate %s', 'plugin' ), $plugin_data['Name'] ) ) . '">' . __( 'Deactivate' ) . '</a>'; 659 670 } 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&plugin=' . urlencode( $plugin_file ) . '&plugin_status=' . $context . '&paged=' . $page . '&s=' . $s, 'resume-plugin_' . $plugin_file ) . '" aria-label="' . esc_attr( sprintf( _x( 'Resume %s', 'plugin' ), $plugin_data['Name'] ) ) . '">' . __( 'Resume' ) . '</a>'; 674 } 660 675 } else { 661 676 if ( current_user_can( 'activate_plugin', $plugin_file ) ) { 662 677 /* translators: %s: plugin name */ … … public function single_row( $item ) { 765 780 $class .= ' update'; 766 781 } 767 782 783 $paused = ! $screen->in_admin( 'network' ) && is_plugin_paused( $plugin_file ); 784 785 if ( $paused ) { 786 $class .= ' paused'; 787 } 788 768 789 $plugin_slug = isset( $plugin_data['slug'] ) ? $plugin_data['slug'] : sanitize_title( $plugin_name ); 769 790 printf( 770 791 '<tr class="%s" data-slug="%s" data-plugin="%s">', … … public function single_row( $item ) { 846 867 * @param array $plugin_data An array of plugin data. 847 868 * @param string $status Status of the plugin. Defaults are 'All', 'Active', 848 869 * 'Inactive', 'Recently Activated', 'Upgrade', 'Must-Use', 849 * 'Drop-ins', 'Search' .870 * 'Drop-ins', 'Search', 'Paused'. 850 871 */ 851 872 $plugin_meta = apply_filters( 'plugin_row_meta', $plugin_meta, $plugin_file, $plugin_data, $status ); 852 873 echo implode( ' | ', $plugin_meta ); 853 874 854 875 echo '</div>'; 855 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 } 888 856 889 echo '</td>'; 857 890 break; 858 891 default: … … public function single_row( $item ) { 886 919 * @param array $plugin_data An array of plugin data. 887 920 * @param string $status Status of the plugin. Defaults are 'All', 'Active', 888 921 * 'Inactive', 'Recently Activated', 'Upgrade', 'Must-Use', 889 * 'Drop-ins', 'Search' .922 * 'Drop-ins', 'Search', 'Paused'. 890 923 */ 891 924 do_action( 'after_plugin_row', $plugin_file, $plugin_data, $status ); 892 925 … … public function single_row( $item ) { 902 935 * @param array $plugin_data An array of plugin data. 903 936 * @param string $status Status of the plugin. Defaults are 'All', 'Active', 904 937 * 'Inactive', 'Recently Activated', 'Upgrade', 'Must-Use', 905 * 'Drop-ins', 'Search' .938 * 'Drop-ins', 'Search', 'Paused'. 906 939 */ 907 940 do_action( "after_plugin_row_{$plugin_file}", $plugin_file, $plugin_data, $status ); 908 941 } -
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() { 468 468 */ 469 469 function _get_dropins() { 470 470 $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 477 479 ); 478 480 479 481 if ( is_multisite() ) { … … function wp_add_privacy_policy_content( $plugin_name, $policy_text ) { 2101 2103 2102 2104 WP_Privacy_Policy_Content::add( $plugin_name, $policy_text ); 2103 2105 } 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 */ 2120 function 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 */ 2144 function 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 */ 2175 function 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 */ 2214 function 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() { 768 768 </script> 769 769 <?php 770 770 } 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 */ 785 function 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 */ 807 function 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 */ 836 function 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 */ 884 function 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 389 389 } 390 390 break; 391 391 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 392 412 default: 393 413 if ( isset( $_POST['checked'] ) ) { 394 414 check_admin_referer( 'bulk-plugins' ); … … 488 508 $_GET['charsout'] 489 509 ); 490 510 $errmsg .= ' ' . __( 'If you notice “headers already sent” 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>.' ); 491 513 } else { 492 514 $errmsg = __( 'Plugin could not be activated because it triggered a <strong>fatal error</strong>.' ); 493 515 } … … 541 563 <div id="message" class="updated notice is-dismissible"><p><?php _e( 'Selected plugins <strong>deactivated</strong>.' ); ?></p></div> 542 564 <?php elseif ( 'update-selected' == $action ) : ?> 543 565 <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> 544 568 <?php endif; ?> 545 569 546 570 <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 33 33 switch_theme( $theme->get_stylesheet() ); 34 34 wp_redirect( admin_url( 'themes.php?activated=true' ) ); 35 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' ) ); 55 exit; 36 56 } elseif ( 'delete' == $_GET['action'] ) { 37 57 check_admin_referer( 'delete-theme_' . $_GET['stylesheet'] ); 38 58 $theme = wp_get_theme( $_GET['stylesheet'] ); … … 195 215 ?> 196 216 <div id="message4" class="error"><p><?php _e( 'You cannot delete a theme while it has an active child theme.' ); ?></p></div> 197 217 <?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 198 226 } 199 227 200 228 $ct = wp_get_theme(); … … 348 376 <p><?php _e( 'The following themes are installed but incomplete.' ); ?></p> 349 377 350 378 <?php 379 $can_resume = current_user_can( 'resume_themes' ); 351 380 $can_delete = current_user_can( 'delete_themes' ); 352 381 $can_install = current_user_can( 'install_themes' ); 353 382 ?> … … 355 384 <tr> 356 385 <th><?php _ex( 'Name', 'theme name' ); ?></th> 357 386 <th><?php _e( 'Description' ); ?></th> 387 <?php if ( $can_resume ) { ?> 388 <td></td> 389 <?php } ?> 358 390 <?php if ( $can_delete ) { ?> 359 391 <td></td> 360 392 <?php } ?> … … 367 399 <td><?php echo $broken_theme->get( 'Name' ) ? $broken_theme->display( 'Name' ) : $broken_theme->get_stylesheet(); ?></td> 368 400 <td><?php echo $broken_theme->errors()->get_error_message(); ?></td> 369 401 <?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 370 423 if ( $can_delete ) { 371 424 $stylesheet = $broken_theme->get_stylesheet(); 372 425 $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 ) { 1047 1047 ); 1048 1048 } 1049 1049 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 */ 1057 function 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 1050 1079 /** 1051 1080 * Add secondary menus. 1052 1081 * -
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 ) { 464 464 } 465 465 } 466 466 break; 467 case 'resume_plugin': 468 $caps[] = 'resume_plugins'; 469 break; 470 case 'resume_theme': 471 $caps[] = 'resume_themes'; 472 break; 467 473 case 'delete_user': 468 474 case 'delete_users': 469 475 // If multisite only super admins can delete users. … … function wp_maybe_grant_install_languages_cap( $allcaps ) { 950 956 951 957 return $allcaps; 952 958 } 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 */ 968 function 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() { 596 596 add_action( 'admin_bar_menu', 'wp_admin_bar_my_account_menu', 0 ); 597 597 add_action( 'admin_bar_menu', 'wp_admin_bar_search_menu', 4 ); 598 598 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 ); 599 600 600 601 // Site related. 601 602 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() { 38 38 return; 39 39 } 40 40 41 if ( ! is_multisite() && wp_recovery_mode()->is_initialized() ) { 42 wp_recovery_mode()->handle_error( $error ); 43 } 44 41 45 // Display the PHP error template. 42 46 $this->display_error_template(); 43 47 } 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 */ 14 class 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 */ 14 final 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 */ 14 final 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 128 Your site recently crashed on ###LOCATION### and may not be working as expected. 129 ###CAUSE### 130 Click the link below to initiate recovery mode and fix the problem. 131 132 This 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 */ 14 final 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 */ 14 class 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 */ 14 class 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 ) { 371 371 $this->parent = new WP_Theme( $this->template, isset( $theme_root_template ) ? $theme_root_template : $this->theme_root, $this ); 372 372 } 373 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.' ) ); 376 } 377 374 378 // We're good. If we didn't retrieve from cache, set it. 375 379 if ( ! is_array( $cache ) ) { 376 380 $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() { 302 302 if ( ! defined( 'COOKIE_DOMAIN' ) ) { 303 303 define( 'COOKIE_DOMAIN', false ); 304 304 } 305 306 if ( ! defined( 'RECOVERY_MODE_COOKIE' ) ) { 307 /** 308 * @since 5.2.0 309 */ 310 define( 'RECOVERY_MODE_COOKIE', 'wordpress_rec_' . COOKIEHASH ); 311 } 305 312 } 306 313 307 314 /** -
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 579 579 580 580 // Capabilities 581 581 add_filter( 'user_has_cap', 'wp_maybe_grant_install_languages_cap', 1 ); 582 add_filter( 'user_has_cap', 'wp_maybe_grant_resume_extensions_caps', 1 ); 582 583 583 584 unset( $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 6 6 * @since 5.2.0 7 7 */ 8 8 9 /** 10 * Get the instance for storing paused plugins. 11 * 12 * @return WP_Paused_Extensions_Storage 13 */ 14 function 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 */ 29 function 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 */ 48 function 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 9 75 /** 10 76 * Registers the shutdown handler for fatal errors. 11 77 * … … function wp_is_fatal_error_handler_enabled() { 52 118 */ 53 119 return apply_filters( 'wp_fatal_error_handler_enabled', $enabled ); 54 120 } 121 122 /** 123 * Access the WordPress Recovery Mode instance. 124 * 125 * @since 5.2.0 126 * 127 * @return WP_Recovery_Mode 128 */ 129 function 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() { 697 697 } 698 698 } 699 699 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 */ 719 function 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 700 737 return $plugins; 701 738 } 702 739 … … function wp_get_active_and_valid_themes() { 725 762 726 763 $themes[] = TEMPLATEPATH; 727 764 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 */ 789 function 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 728 807 return $themes; 729 808 } 730 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 */ 819 function 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 */ 830 function 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 */ 867 function 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 731 905 /** 732 906 * Set internal encoding. 733 907 * -
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() { 439 439 } 440 440 441 441 // 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 ) ) {442 if ( ! 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 ) ) { 443 443 $action = 'login'; 444 444 } 445 445 … … function retrieve_password() { 1028 1028 $errors->add( 'registered', __( 'Registration complete. Please check your email.' ), 'message' ); 1029 1029 } elseif ( strpos( $redirect_to, 'about.php?updated' ) ) { 1030 1030 $errors->add( 'updated', __( '<strong>You have successfully updated WordPress!</strong> Please log back in to see what’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' ); 1031 1033 } 1032 1034 } 1033 1035 -
src/wp-settings.php
diff --git a/src/wp-settings.php b/src/wp-settings.php index 5f52637409..fa9f9a3a25 100644
a b 17 17 18 18 // Include files required for initialization. 19 19 require( ABSPATH . WPINC . '/load.php' ); 20 require( ABSPATH . WPINC . '/class-wp-paused-extensions-storage.php' ); 20 21 require( ABSPATH . WPINC . '/class-wp-fatal-error-handler.php' ); 22 require( ABSPATH . WPINC . '/class-wp-recovery-mode-cookie-service.php' ); 23 require( ABSPATH . WPINC . '/class-wp-recovery-mode-key-service.php' ); 24 require( ABSPATH . WPINC . '/class-wp-recovery-mode-link-service.php' ); 25 require( ABSPATH . WPINC . '/class-wp-recovery-mode-email-service.php' ); 26 require( ABSPATH . WPINC . '/class-wp-recovery-mode.php' ); 21 27 require( ABSPATH . WPINC . '/error-protection.php' ); 22 28 require( ABSPATH . WPINC . '/default-constants.php' ); 23 29 require_once( ABSPATH . WPINC . '/plugin.php' ); … … 345 351 // Register the default theme directory root 346 352 register_theme_directory( get_theme_root() ); 347 353 354 if ( ! is_multisite() ) { 355 // Handle users requesting a recovery mode link and initiating recovery mode. 356 wp_recovery_mode()->initialize(); 357 } 358 348 359 // Load active plugins. 349 360 foreach ( wp_get_active_and_valid_plugins() as $plugin ) { 350 361 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 */ 6 class 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 */ 6 class 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 ) { 101 101 'remove_users' => array( 'administrator' ), 102 102 'switch_themes' => array( 'administrator' ), 103 103 'edit_dashboard' => array( 'administrator' ), 104 'resume_plugins' => array( 'administrator' ), 105 'resume_themes' => array( 'administrator' ), 104 106 105 107 'moderate_comments' => array( 'administrator', 'editor' ), 106 108 'manage_categories' => array( 'administrator', 'editor' ), … … function _meta_filter( $meta_value, $meta_key, $meta_type ) { 181 183 'remove_users' => array( 'administrator' ), 182 184 'switch_themes' => array( 'administrator' ), 183 185 'edit_dashboard' => array( 'administrator' ), 186 'resume_plugins' => array( 'administrator' ), 187 'resume_themes' => array( 'administrator' ), 184 188 185 189 'moderate_comments' => array( 'administrator', 'editor' ), 186 190 'manage_categories' => array( 'administrator', 'editor' ), … … public function testPrimitiveCapsTestsAreCorrect() { 392 396 $actual['editor'], 393 397 $actual['author'], 394 398 $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'] 396 403 ); 397 404 398 405 unset( … … public function testMetaCapsTestsAreCorrect() { 454 461 // Singular object meta capabilities (where an object ID is passed) are not tested: 455 462 $expected['activate_plugin'], 456 463 $expected['deactivate_plugin'], 464 $expected['resume_plugin'], 465 $expected['resume_theme'], 457 466 $expected['remove_user'], 458 467 $expected['promote_user'], 459 468 $expected['edit_user'],