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 a07945a9733db..ff471650ea7c4 100644 --- a/src/wp-admin/includes/class-wp-plugins-list-table.php +++ b/src/wp-admin/includes/class-wp-plugins-list-table.php @@ -791,9 +791,9 @@ public function single_row( $item ) { $class .= ' update'; } - $paused = is_plugin_paused( $plugin_file ); - $paused_on_network_sites_count = $screen->in_admin( 'network' ) ? count_paused_plugin_sites_for_network( $plugin_file ) : 0; - if ( $paused || $paused_on_network_sites_count ) { + $paused = is_plugin_paused( $plugin_file ); + + if ( $paused ) { $class .= ' paused'; } @@ -885,15 +885,8 @@ public function single_row( $item ) { echo ''; - if ( $paused || $paused_on_network_sites_count ) { - $notice_text = __( 'This plugin failed to load properly and was paused within the admin backend.' ); - if ( $screen->in_admin( 'network' ) && $paused_on_network_sites_count ) { - $notice_text = sprintf( - /* translators: %s: number of sites */ - _n( 'This plugin failed to load properly and was paused within the admin backend for %s site.', 'This plugin failed to load properly and was paused within the admin backend for %s sites.', $paused_on_network_sites_count ), - number_format_i18n( $paused_on_network_sites_count ) - ); - } + if ( $paused ) { + $notice_text = __( 'This plugin failed to load properly and is paused during recovery mode.' ); printf( '
%s
', $notice_text ); @@ -911,11 +904,18 @@ public function single_row( $item ) { $error['type'] = $core_errors[ $error['type'] ]; + if ( empty( $error['wp_is_protected'] ) ) { + /* translators: 1: error type, 2: error line number, 3: error file name, 4: error message */ + $error_message = __( 'The plugin caused an error of type %1$s in line %2$s of the file %3$s. Error message: %4$s' ); + } else { + /* translators: 1: error type, 2: error line number, 3: error file name, 4: error message */ + $error_message = __( 'The plugin caused an error in the admin backend of type %1$s in line %2$s of the file %3$s. Error message: %4$s' ); + } + printf( '%s
{$error['type']}",
"{$error['line']}",
"{$error['file']}",
diff --git a/src/wp-includes/class-wp-fatal-error-handler.php b/src/wp-includes/class-wp-fatal-error-handler.php
index 0299b67a48528..45abde34a3744 100644
--- a/src/wp-includes/class-wp-fatal-error-handler.php
+++ b/src/wp-includes/class-wp-fatal-error-handler.php
@@ -40,10 +40,12 @@ public function handle() {
// If the error was stored and thus the extension paused,
// redirect the request to catch multiple errors in one go.
- if ( $this->store_error( $error ) ) {
+ if ( $this->store_error( $error ) && wp_is_recovery_mode() ) {
$this->redirect_protected();
}
+ maybe_send_recovery_mode_email();
+
// Display the PHP error template.
$this->display_error_template();
} catch ( Exception $e ) {
@@ -83,11 +85,6 @@ protected function detect_error() {
* @return bool True if the error was stored successfully, false otherwise.
*/
protected function store_error( $error ) {
- // Do not pause extensions if they only crash on a non-protected endpoint.
- if ( ! is_protected_endpoint() ) {
- return false;
- }
-
return wp_record_extension_error( $error );
}
@@ -102,11 +99,6 @@ protected function store_error( $error ) {
* @since 5.1.0
*/
protected function redirect_protected() {
- // Do not redirect requests on non-protected endpoints.
- if ( ! is_protected_endpoint() ) {
- return;
- }
-
// Pluggable is usually loaded after plugins, so we manually include it here for redirection functionality.
if ( ! function_exists( 'wp_redirect' ) ) {
include ABSPATH . WPINC . '/pluggable.php';
@@ -171,9 +163,9 @@ protected function display_default_error_template() {
'response' => 500,
'exit' => false,
);
- if ( function_exists( 'admin_url' ) ) {
- $args['link_url'] = admin_url();
- $args['link_text'] = __( 'Log into the admin backend to fix this.' );
+ if ( function_exists( 'wp_login_url' ) ) {
+ $args['link_url'] = get_recovery_mode_request_url();
+ $args['link_text'] = __( 'Request a Recovery Mode email to fix this.' );
}
/**
diff --git a/src/wp-includes/default-constants.php b/src/wp-includes/default-constants.php
index 1d3fd5df9873a..0a71a91f2660d 100644
--- a/src/wp-includes/default-constants.php
+++ b/src/wp-includes/default-constants.php
@@ -302,6 +302,11 @@ function wp_cookie_constants() {
if ( ! defined( 'COOKIE_DOMAIN' ) ) {
define( 'COOKIE_DOMAIN', false );
}
+
+ /**
+ * @since 5.1.0
+ */
+ define( 'RECOVERY_MODE_COOKIE', 'wordpress_rec_' . COOKIEHASH );
}
/**
diff --git a/src/wp-includes/error-protection.php b/src/wp-includes/error-protection.php
index c9b57409606a3..e045efde574e1 100644
--- a/src/wp-includes/error-protection.php
+++ b/src/wp-includes/error-protection.php
@@ -88,6 +88,8 @@ function wp_record_extension_error( $error ) {
$parts = explode( '/', $path );
$extension = array_shift( $parts );
+ $error['wp_is_protected'] = is_protected_endpoint();
+
return call_user_func( $callback )->record( $extension, $error );
}
diff --git a/src/wp-includes/load.php b/src/wp-includes/load.php
index 7fe5566e7e092..6951845f2aae1 100644
--- a/src/wp-includes/load.php
+++ b/src/wp-includes/load.php
@@ -701,7 +701,7 @@ function wp_get_active_and_valid_plugins() {
* Remove plugins from the list of active plugins when we're on an endpoint
* that should be protected against WSODs and the plugin is paused.
*/
- if ( is_protected_endpoint() ) {
+ if ( wp_is_recovery_mode() ) {
$plugins = wp_skip_paused_plugins( $plugins );
}
@@ -766,7 +766,7 @@ function wp_get_active_and_valid_themes() {
* Remove themes from the list of active themes when we're on an endpoint
* that should be protected against WSODs and the theme is paused.
*/
- if ( is_protected_endpoint() ) {
+ if ( wp_is_recovery_mode() ) {
$themes = wp_skip_paused_themes( $themes );
// If no active and valid themes exist, skip loading themes.
@@ -1289,6 +1289,483 @@ function wp_using_themes() {
return apply_filters( 'wp_using_themes', defined( 'WP_USE_THEMES' ) && WP_USE_THEMES );
}
+/**
+ * Is WordPress in Recovery Mode.
+ *
+ * In this mode, plugins or themes that cause WSODs will be paused.
+ *
+ * @since 5.1.0
+ *
+ * @return bool
+ */
+function wp_is_recovery_mode() {
+ /**
+ * Filters whether WordPress is in Recovery Mode.
+ *
+ * @since 5.1.0
+ *
+ * @param bool $wp_is_recovery_mode Whether WordPress is in recovery mode.
+ */
+ return apply_filters( 'wp_is_recovery_mode', defined( 'WP_RECOVERY_MODE' ) && WP_RECOVERY_MODE );
+}
+
+/**
+ * Create a recovery mode key for a user.
+ *
+ * @since 5.1.0
+ *
+ * @global PasswordHash $wp_hasher
+ *
+ * @return string Recovery mode key.
+ */
+function generate_and_store_recovery_mode_key() {
+
+ global $wp_hasher;
+
+ if ( ! function_exists( 'wp_generate_password' ) ) {
+ require_once ABSPATH . WPINC . '/pluggable.php';
+ }
+
+ $key = wp_generate_password( 20, false );
+
+ /**
+ * Fires when a recovery mode key is generated for a user.
+ *
+ * @since 5.1.0
+ *
+ * @param string $key The recovery mode key.
+ */
+ do_action( 'generate_recovery_mode_key', $key );
+
+ if ( empty( $wp_hasher ) ) {
+ require_once ABSPATH . WPINC . '/class-phpass.php';
+ $wp_hasher = new PasswordHash( 8, true );
+ }
+
+ $hashed = $wp_hasher->HashPassword( $key );
+
+ update_site_option( 'recovery_key', array(
+ 'hashed_key' => $hashed,
+ 'created_at' => time(),
+ ) );
+
+ return $key;
+}
+
+/**
+ * Verify if the recovery mode key is correct.
+ *
+ * @since 5.1.0
+ *
+ * @param string $key The unhashed key.
+ *
+ * @return true|WP_Error
+ */
+function validate_recovery_mode_key( $key ) {
+
+ $record = get_site_option( 'recovery_key' );
+
+ if ( ! $record ) {
+ return new WP_Error( 'no_recovery_key_set', __( 'Recovery Mode not initialized.' ) );
+ }
+
+ if ( ! is_array( $record ) || ! isset( $record['hashed_key'], $record['created_at'] ) ) {
+ return new WP_Error( 'invalid_recovery_key_format', __( 'Invalid recovery key format.' ) );
+ }
+
+ if ( ! function_exists( 'wp_check_password' ) ) {
+ require_once ABSPATH . WPINC . '/pluggable.php';
+ }
+
+ if ( ! wp_check_password( $key, $record['hashed_key'] ) ) {
+ return new WP_Error( 'hash_mismatch', __( 'Invalid recovery key.' ) );
+ }
+
+ $valid_for = HOUR_IN_SECONDS;
+
+ if ( time() > $record['created_at'] + $valid_for ) {
+ return new WP_Error( 'key_expired', __( 'Recovery key expired.' ) );
+ }
+
+ return true;
+}
+
+/**
+ * A form of `wp_hash()` specific to Recovery Mode.
+ *
+ * We cannot use `wp_hash()` because it is defined in `pluggable.php` which is not loaded until after plugins are loaded,
+ * which is too late to verify the recovery mode cookie.
+ *
+ * This tries to use the `AUTH` salts first, but if they aren't valid specific salts will be generated and stored.
+ *
+ * @param string $data
+ *
+ * @return string|false
+ */
+function recovery_mode_hash( $data ) {
+
+ if ( ! defined( 'AUTH_KEY' ) || AUTH_KEY === 'put your unique phrase here' ) {
+ $auth_key = get_site_option( 'recovery_mode_auth_key' );
+
+ if ( ! $auth_key ) {
+ if ( ! function_exists( 'wp_generate_password' ) ) {
+ require_once ABSPATH . WPINC . '/pluggable.php';
+ }
+
+ $auth_key = wp_generate_password( 64, true, true );
+ update_site_option( 'recovery_mode_auth_key', $auth_key );
+ }
+ } else {
+ $auth_key = AUTH_KEY;
+ }
+
+ if ( ! defined( 'AUTH_SALT' ) || 'put your unique phrase here' === AUTH_SALT || $auth_key === AUTH_SALT ) {
+ $auth_salt = get_site_option( 'recovery_mode_auth_salt' );
+
+ if ( ! $auth_salt ) {
+ if ( ! function_exists( 'wp_generate_password' ) ) {
+ require_once ABSPATH . WPINC . '/pluggable.php';
+ }
+
+ $auth_salt = wp_generate_password( 64, true, true );
+ update_site_option( 'recovery_mode_auth_salt', $auth_salt );
+ }
+ } else {
+ $auth_salt = AUTH_SALT;
+ }
+
+ $secret = $auth_key . $auth_salt;
+
+ return hash_hmac( 'sha1', $data, $secret );
+}
+
+/**
+ * Generate the recovery mode cookie value.
+ *
+ * @since 5.1.0
+ *
+ * @return string
+ */
+function generate_recovery_mode_cookie() {
+
+ if ( ! function_exists( 'wp_generate_password' ) ) {
+ require_once ABSPATH . WPINC . '/pluggable.php';
+ }
+
+ $to_sign = sprintf( 'recovery_mode|%s|%s', time(), wp_generate_password( 20, false ) );
+ $signed = recovery_mode_hash( $to_sign );
+
+ return base64_encode( sprintf( '%s|%s', $to_sign, $signed ) );
+}
+
+/**
+ * Set the recovery mode cookie.
+ *
+ * @since 5.1.0
+ */
+function set_recovery_mode_cookie() {
+
+ $value = generate_recovery_mode_cookie();
+
+ setcookie( RECOVERY_MODE_COOKIE, $value, 0, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );
+
+ if ( COOKIEPATH !== SITECOOKIEPATH ) {
+ setcookie( RECOVERY_MODE_COOKIE, $value, 0, SITECOOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );
+ }
+}
+
+/**
+ * Clear the recovery mode cookie.
+ *
+ * @sicne 5.1.0
+ */
+function clear_recovery_mode_cookie() {
+ setcookie( RECOVERY_MODE_COOKIE, ' ', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN );
+ setcookie( RECOVERY_MODE_COOKIE, ' ', time() - YEAR_IN_SECONDS, SITECOOKIEPATH, COOKIE_DOMAIN );
+}
+
+/**
+ * Validate the recovery mode cookie.
+ *
+ * @since 5.1.0
+ *
+ * @param string $cookie Optionally, specify the cookie value instead of fetching from the super global.
+ *
+ * @return true|WP_Error
+ */
+function validate_recovery_mode_cookie( $cookie = '' ) {
+
+ if ( ! $cookie ) {
+ if ( empty( $_COOKIE[ RECOVERY_MODE_COOKIE ] ) ) {
+ return new WP_Error( 'no_cookie' );
+ }
+
+ $cookie = $_COOKIE[ RECOVERY_MODE_COOKIE ];
+ }
+
+ $cookie = base64_decode( $cookie );
+ $parts = explode( '|', $cookie );
+
+ if ( 4 !== count( $parts ) ) {
+ return new WP_Error( 'invalid_format', __( 'Invalid cookie format.' ) );
+ }
+
+ list( , $created_at, $random, $signature ) = $parts;
+
+ if ( ! ctype_digit( $created_at ) ) {
+ return new WP_Error( 'invalid_created_at', __( 'Invalid cookie format.' ) );
+ }
+
+ /**
+ * Filter the length of time a Recovery Mode cookie is valid for.
+ *
+ * @since 5.1.0
+ *
+ * @param int $length Length in seconds.
+ */
+ $length = apply_filters( 'recovery_mode_cookie_length', WEEK_IN_SECONDS );
+
+ if ( time() > $created_at + $length ) {
+ return new WP_Error( 'expired', __( 'Cookie expired.' ) );
+ }
+
+ $to_sign = sprintf( 'recovery_mode|%s|%s', $created_at, $random );
+ $hashed = recovery_mode_hash( $to_sign );
+
+ if ( ! hash_equals( $signature, $hashed ) ) {
+ return new WP_Error( 'signature_mismatch', __( 'Invalid cookie.' ) );
+ }
+
+ return true;
+}
+
+/**
+ * Handle initializing Recovery Mode and sending a Recovery Mode link.
+ *
+ * @since 5.1.0
+ */
+function handle_recovery_mode_actions() {
+
+ if ( isset( $_COOKIE[ RECOVERY_MODE_COOKIE ] ) ) {
+ $validated = validate_recovery_mode_cookie();
+
+ if ( is_wp_error( $validated ) ) {
+ clear_recovery_mode_cookie();
+
+ wp_die( $validated, '', array(
+ 'link_url' => get_recovery_mode_request_url(),
+ 'link_text' => __( 'Send a new email.' ),
+ ) );
+ }
+
+ if ( ! defined( 'WP_RECOVERY_MODE' ) ) {
+ define( 'WP_RECOVERY_MODE', true );
+ }
+
+ return;
+ }
+
+ if ( ! isset( $GLOBALS['pagenow'] ) || 'wp-login.php' !== $GLOBALS['pagenow'] ) {
+ return;
+ }
+
+ if ( isset( $_GET['action'], $_GET['rm_key'] ) && 'begin_recovery_mode' === $_GET['action'] ) {
+ $validated = validate_recovery_mode_key( $_GET['rm_key'] );
+
+ if ( is_wp_error( $validated ) ) {
+ wp_die( $validated, '', array(
+ 'link_url' => get_recovery_mode_request_url(),
+ 'link_text' => __( 'Send a new email.' ),
+ ) );
+ }
+
+ set_recovery_mode_cookie();
+
+ // This should be loaded by set_recovery_mode_cookie() but load it again to be safe.
+ if ( ! function_exists( 'wp_redirect' ) ) {
+ require_once ABSPATH . WPINC . '/pluggable.php';
+ }
+
+ $url = add_query_arg( 'action', 'begun_recovery_mode', wp_login_url() );
+ $message = '';
+
+ wp_die( $message, '', array(
+ 'response' => 200,
+ 'link_url' => $url,
+ 'link_text' => __( 'Continue to Login' ),
+ ) );
+ }
+
+ if ( isset( $_GET['action'] ) && 'request_recovery_mode' === $_GET['action'] ) {
+ $sent = maybe_send_recovery_mode_email();
+
+ if ( ! function_exists( 'wp_redirect' ) ) {
+ require_once ABSPATH . WPINC . '/pluggable.php';
+ }
+
+ if ( is_wp_error( $sent ) ) {
+ $message = $sent;
+ $args = array();
+ } else {
+ $message = __( 'Recovery Link sent to the Site Admin email address.' );
+ $args = array( 'response' => 200 );
+ }
+
+ wp_die( $message, '', $args );
+ exit;
+ }
+}
+
+/**
+ * Get a URL to request a recovery mode link be emailed to the user.
+ *
+ * @since 5.1.0
+ *
+ * @return string
+ */
+function get_recovery_mode_request_url() {
+ $url = add_query_arg( 'action', 'request_recovery_mode', wp_login_url() );
+
+ /**
+ * Filter the URL to request a recovery mode link be emailed to the user.
+ *
+ * @since 5.1.0
+ *
+ * @param string $url
+ */
+ return apply_filters( 'recovery_mode_request_url', $url );
+}
+
+/**
+ * Get a URL to begin recovery mode.
+ *
+ * @since 5.1.0
+ *
+ * @param string $key Recovery Mode key created by {@see generate_and_store_recovery_mode_key()}
+ *
+ * @return string
+ */
+function get_recovery_mode_begin_url( $key ) {
+
+ $url = add_query_arg( array(
+ 'action' => 'begin_recovery_mode',
+ 'rm_key' => $key,
+ ), wp_login_url() );
+
+ /**
+ * Filter the URL to begin recovery mode.
+ *
+ * @since 5.1.0
+ *
+ * @param string $url
+ * @param string $key
+ */
+ return apply_filters( 'recovery_mode_begin_url', $url, $key );
+}
+
+/**
+ * Send the recovery mode email if the rate limit has not been sent.
+ *
+ * @since 5.1.0
+ *
+ * @return true|WP_Error True if email sent, WP_Error otherwise.
+ */
+function maybe_send_recovery_mode_email() {
+
+ /**
+ * Filter the rate limit between sending new recovery mode email links.
+ *
+ * @since 5.1.0
+ *
+ * @param int $rate_limit Time to wait in seconds.
+ */
+ $rate_limit = apply_filters( 'recovery_mode_email_rate_limit', HOUR_IN_SECONDS );
+
+ $last_sent = get_site_option( 'recovery_mode_email_last_sent' );
+
+ if ( ! $last_sent || time() > $last_sent + $rate_limit ) {
+ $sent = send_recovery_mode_email();
+ update_site_option( 'recovery_mode_email_last_sent', time() );
+
+ if ( $sent ) {
+ return true;
+ }
+
+ return new WP_Error( 'email_failed', __( 'The email could not be sent. Possible reason: your host may have disabled the mail() function.' ) );
+ }
+
+ $error = sprintf(
+ /* translators: 1. Last sent as a human time diff 2. Wait time as a human time diff. */
+ __( 'A recovery link was already sent %1$s ago. Please wait another %2$s before requesting a new email.' ),
+ human_time_diff( $last_sent ),
+ human_time_diff( $last_sent + $rate_limit )
+ );
+
+ return new WP_Error( 'email_sent_already', $error );
+}
+
+/**
+ * Send the Recovery Mode email to the site admin email address.
+ *
+ * @since 5.1.0
+ *
+ * @return bool Whether the email was sent successfully.
+ */
+function send_recovery_mode_email() {
+
+ $key = generate_and_store_recovery_mode_key();
+ $url = get_recovery_mode_begin_url( $key );
+ $blogname = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES );
+
+ $switched_locale = false;
+
+ // The switch_to_locale() function is loaded before it can actually be used.
+ if ( function_exists( 'switch_to_locale' ) && isset( $GLOBALS['wp_locale_switcher'] ) ) {
+ $switched_locale = switch_to_locale( get_locale() );
+ }
+
+ $message = __(
+ 'Howdy,
+
+Your site recently experienced a fatal error. Click the link below to initiate recovery mode to fix the problem.
+
+This link expires in one hour.
+
+###LINK###'
+ );
+ $message = str_replace( '###LINK###', $url, $message );
+
+ $email = array(
+ 'to' => get_option( 'admin_email' ),
+ 'subject' => __( '[%s] Recovery Mode' ),
+ 'message' => $message,
+ 'headers' => '',
+ );
+
+ /**
+ * Filter the contents of the Recovery Mode email.
+ *
+ * @since 5.1.0
+ *
+ * @param array $email Used to build wp_mail().
+ * @param string $key Recovery mode key.
+ */
+ $email = apply_filters( 'recovery_mode_email', $email, $key );
+
+ $sent = wp_mail(
+ $email['to'],
+ wp_specialchars_decode( sprintf( $email['subject'], $blogname ) ),
+ $email['message'],
+ $email['headers']
+ );
+
+ if ( $switched_locale ) {
+ restore_previous_locale();
+ }
+
+ return $sent;
+}
+
/**
* Determines whether we are currently on an endpoint that should be protected against WSODs.
*
diff --git a/src/wp-includes/ms-load.php b/src/wp-includes/ms-load.php
index 4f630cce27a1f..91c9c8c301525 100644
--- a/src/wp-includes/ms-load.php
+++ b/src/wp-includes/ms-load.php
@@ -53,14 +53,6 @@ function wp_get_active_network_plugins() {
}
}
- /*
- * Remove plugins from the list of active plugins when we're on an endpoint
- * that should be protected against WSODs and the plugin is paused.
- */
- if ( is_protected_endpoint() ) {
- $plugins = wp_skip_paused_plugins( $plugins );
- }
-
return $plugins;
}
diff --git a/src/wp-includes/pluggable.php b/src/wp-includes/pluggable.php
index 0e9d4ad2f0258..90b9dba6df8de 100644
--- a/src/wp-includes/pluggable.php
+++ b/src/wp-includes/pluggable.php
@@ -979,6 +979,8 @@ function wp_clear_auth_cookie() {
// Post password cookie
setcookie( 'wp-postpass_' . COOKIEHASH, ' ', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN );
+
+ clear_recovery_mode_cookie();
}
endif;
diff --git a/src/wp-login.php b/src/wp-login.php
index 1a302a35f132b..4171e9065b6a1 100644
--- a/src/wp-login.php
+++ b/src/wp-login.php
@@ -438,7 +438,7 @@ function retrieve_password() {
}
// Validate action so as to default to the login screen.
-if ( ! in_array( $action, array( 'postpass', 'logout', 'lostpassword', 'retrievepassword', 'resetpass', 'rp', 'register', 'login', 'confirmaction' ), true ) && false === has_filter( 'login_form_' . $action ) ) {
+if ( ! in_array( $action, array( 'postpass', 'logout', 'lostpassword', 'retrievepassword', 'resetpass', 'rp', 'register', 'login', 'confirmaction', 'begun_recovery_mode' ), true ) && false === has_filter( 'login_form_' . $action ) ) {
$action = 'login';
}
@@ -1024,6 +1024,8 @@ function retrieve_password() {
$errors->add( 'registered', __( 'Registration complete. Please check your email.' ), 'message' );
} elseif ( strpos( $redirect_to, 'about.php?updated' ) ) {
$errors->add( 'updated', __( 'You have successfully updated WordPress! Please log back in to see what’s new.' ), 'message' );
+ } elseif ( 'begun_recovery_mode' === $action ) {
+ $errors->add( 'begun_recovery_mode', __( 'Recovery Mode Initialized. Please login to continue.' ), 'message' );
}
}
diff --git a/src/wp-settings.php b/src/wp-settings.php
index e48208beb6922..60f1e50485c81 100644
--- a/src/wp-settings.php
+++ b/src/wp-settings.php
@@ -342,6 +342,9 @@
// Register the default theme directory root
register_theme_directory( get_theme_root() );
+// Handle users requesting a recovery mode link and initiating recovery mode.
+handle_recovery_mode_actions();
+
// Load active plugins.
foreach ( wp_get_active_and_valid_plugins() as $plugin ) {
wp_register_plugin_realpath( $plugin );
diff --git a/tests/phpunit/tests/recovery-mode.php b/tests/phpunit/tests/recovery-mode.php
new file mode 100644
index 0000000000000..a7d2a69781ccf
--- /dev/null
+++ b/tests/phpunit/tests/recovery-mode.php
@@ -0,0 +1,130 @@
+user->create( array( 'role' => 'subscriber' ) );
+ self::$administrator = self::factory()->user->create( array( 'role' => 'administrator' ) );
+
+ return parent::setUpBeforeClass();
+ }
+
+ public static function tearDownAfterClass() {
+ wp_delete_user( self::$subscriber );
+ wp_delete_user( self::$administrator );
+
+ return parent::tearDownAfterClass();
+ }
+
+ public function test_generate_and_store_returns_recovery_key() {
+ $key = generate_and_store_recovery_mode_key();
+
+ $this->assertNotWPError( $key );
+ }
+
+ public function test_verify_recovery_mode_key_returns_wp_error_if_no_key_set() {
+ $error = validate_recovery_mode_key( 'abcd' );
+
+ $this->assertWPError( $error );
+ $this->assertEquals( 'no_recovery_key_set', $error->get_error_code() );
+ }
+
+ public function test_verify_recovery_mode_key_returns_wp_error_if_stored_format_is_invalid() {
+ update_site_option( 'recovery_key', 'gibberish' );
+ $error = validate_recovery_mode_key( 'abcd' );
+
+ $this->assertWPError( $error );
+ $this->assertEquals( 'invalid_recovery_key_format', $error->get_error_code() );
+ }
+
+ public function test_verify_recovery_mode_key_returns_wp_error_if_empty_key() {
+ generate_and_store_recovery_mode_key();
+ $error = validate_recovery_mode_key( '' );
+
+ $this->assertWPError( $error );
+ $this->assertEquals( 'hash_mismatch', $error->get_error_code() );
+ }
+
+ public function test_verify_recovery_mode_key_returns_wp_error_if_hash_mismatch() {
+ generate_and_store_recovery_mode_key();
+ $error = validate_recovery_mode_key( 'abcd' );
+
+ $this->assertWPError( $error );
+ $this->assertEquals( 'hash_mismatch', $error->get_error_code() );
+ }
+
+ public function test_verify_recovery_mode_key_returns_wp_error_if_expired() {
+ $key = generate_and_store_recovery_mode_key();
+
+ $record = get_site_option( 'recovery_key' );
+ $record['created_at'] = time() - HOUR_IN_SECONDS - 30;
+ update_site_option( 'recovery_key', $record );
+
+ $error = validate_recovery_mode_key( $key );
+
+ $this->assertWPError( $error );
+ $this->assertEquals( 'key_expired', $error->get_error_code() );
+ }
+
+ public function test_verify_recovery_mode_key_returns_true_for_valid_key() {
+
+ $key = generate_and_store_recovery_mode_key();
+ $this->assertTrue( validate_recovery_mode_key( $key ) );
+ }
+
+ public function test_validate_recovery_mode_cookie_returns_wp_error_if_invalid_format() {
+
+ $error = validate_recovery_mode_cookie( 'gibbersih' );
+ $this->assertWPError( $error );
+ $this->assertEquals( 'invalid_format', $error->get_error_code() );
+
+ $error = validate_recovery_mode_cookie( base64_encode( 'test|data|format' ) );
+ $this->assertWPError( $error );
+ $this->assertEquals( 'invalid_format', $error->get_error_code() );
+
+ $error = validate_recovery_mode_cookie( base64_encode( 'test|data|format|to|long' ) );
+ $this->assertWPError( $error );
+ $this->assertEquals( 'invalid_format', $error->get_error_code() );
+ }
+
+ public function test_validate_recovery_mode_cookie_returns_wp_error_if_expired() {
+
+ $to_sign = sprintf( 'recovery_mode|%s|%s', time() - WEEK_IN_SECONDS - 30, wp_generate_password( 20, false ) );
+ $signed = recovery_mode_hash( $to_sign );
+ $cookie = base64_encode( sprintf( '%s|%s', $to_sign, $signed ) );
+
+ $error = validate_recovery_mode_cookie( $cookie );
+ $this->assertWPError( $error );
+ $this->assertEquals( 'expired', $error->get_error_code() );
+ }
+
+ public function test_validate_recovery_mode_cookie_returns_wp_error_if_signature_mismatch() {
+
+ $cookie = generate_recovery_mode_cookie();
+ $cookie .= 'gibbersih';
+
+ $error = validate_recovery_mode_cookie( $cookie );
+ $this->assertWPError( $error );
+ $this->assertEquals( 'signature_mismatch', $error->get_error_code() );
+ }
+
+ public function test_validate_recovery_mode_cookie_returns_wp_error_if_created_at_is_invalid_format() {
+
+ $to_sign = sprintf( 'recovery_mode|%s|%s', 'month', wp_generate_password( 20, false ) );
+ $signed = recovery_mode_hash( $to_sign );
+ $cookie = base64_encode( sprintf( '%s|%s', $to_sign, $signed ) );
+
+ $error = validate_recovery_mode_cookie( $cookie );
+ $this->assertWPError( $error );
+ $this->assertEquals( 'invalid_created_at', $error->get_error_code() );
+ }
+
+ public function test_generate_and_validate_recovery_mode_cookie_returns_true_for_valid_cookie() {
+
+ $cookie = generate_recovery_mode_cookie();
+ $this->assertTrue( validate_recovery_mode_cookie( $cookie ) );
+ }
+}