WordPress.org

Make WordPress Core

Ticket #47954: 47954.diff

File 47954.diff, 16.8 KB (added by donmhico, 2 years ago)

Email admin a restore link to revert siteurl and home option changes.

  • new file src/wp-includes/class-wp-restore-siteurl.php

    diff --git src/wp-includes/class-wp-restore-siteurl.php src/wp-includes/class-wp-restore-siteurl.php
    new file mode 100644
    index 0000000000..b7bc274346
    - +  
     1<?php
     2/**
     3 * Handles the restore siteurl operation when the user updates either 'home' or 'siteurl' options
     4 * and wants to revert it back.
     5 *
     6 * @since 5.3.0
     7 *
     8 * @link https://core.trac.wordpress.org/ticket/47954
     9 */
     10class WP_Restore_Siteurl {
     11    /**
     12     * Expiry time of the created transients for Restore Siteurl.
     13     *
     14     * @since 5.3.0
     15     * @var string
     16     */
     17    const RESTORE_TRANSIENTS_EXPIRY_IN_SECONDS = 1800;
     18
     19    /**
     20     * Restore key to authenticate the restore siteurl request.
     21     *
     22     * @since 5.3.0
     23     * @var string
     24     */
     25    private $restore_key = null;
     26
     27    /**
     28     * Is the restore siteurl email sent to the user.
     29     *
     30     * @since 5.3.0
     31     * @var boolean
     32     */
     33    private $is_restore_siteurl_email_sent = false;
     34
     35    /**
     36     * Old 'home' option value.
     37     *
     38     * @since 5.3.0
     39     * @var string
     40     */
     41    private $old_home_value = null;
     42
     43    /**
     44     * Hook the functions.
     45     *
     46     * @since 5.3.0
     47     */
     48    public function __construct() {
     49        add_action( 'registered_taxonomy', array( $this, 'perform_siteurl_restore' ) );
     50        add_action( 'updated_option', array( $this, 'setup_siteurl_restore' ), 10, 3 );
     51        add_action( 'admin_init', array( $this, 'restore_siteurl_success_notice' ) );
     52    }
     53
     54    /**
     55     * Generates a restore key.
     56     *
     57     * @since 5.3.0
     58     *
     59     * @return string $restore_key A random string to authenticate the restore siteurl request.
     60     */
     61    private function generate_restore_key() {
     62        if ( null === $this->restore_key ) {
     63            $this->restore_key = wp_generate_password( 16, false );
     64        }
     65
     66        return $this->restore_key;
     67    }
     68
     69    /**
     70     * Listen to updates for 'home' and 'siteurl' options.
     71     *
     72     * This function performs the following tasks when either 'home' and 'siteurl' options are updated:
     73     * 1. Caches the old 'home' value if it's the option updated.
     74     * 2. Creates a 'siteurl_restore_key' transient that will be used to authenticate the restore operation.
     75     * 3. Backups the old 'home' or 'siteurl' values.
     76     * 4. Sends an email to the admin with the restore link.
     77     *
     78     * @since 5.3.0
     79     *
     80     * @param string $option    Name of the updated option.
     81     * @param string $old_value The old option value.
     82     * @param string $value     The new option value.
     83     */
     84    public function setup_siteurl_restore( $option, $old_value, $value ) {
     85        if ( 'siteurl' === $option || 'home' === $option ) {
     86            // Prevent Restore Siteurl protocol if the update is the restoration.
     87            $backup_value = get_transient( "old_{$option}" );
     88            if ( $value === $backup_value ) {
     89                return;
     90            }
     91
     92            // Backup the old 'home' option value.
     93            if ( 'home' === $option ) {
     94                $this->old_home_value = $old_value;
     95            }
     96
     97            $restore_key_transient = get_transient( 'siteurl_restore_key' );
     98            $set_restore_key_transient = false;
     99            if ( $this->generate_restore_key() != $restore_key_transient ) {
     100                // Create a restore key transient.
     101                $set_restore_key_transient = set_transient( 'siteurl_restore_key', $this->generate_restore_key(), self::RESTORE_TRANSIENTS_EXPIRY_IN_SECONDS );
     102            }
     103
     104            // Keep a backup of the old `siteurl` and `home`.
     105            delete_transient( "old_{$option}" );
     106            $set_backup_transient = set_transient( "old_{$option}", $old_value, self::RESTORE_TRANSIENTS_EXPIRY_IN_SECONDS );
     107           
     108            if ( $set_restore_key_transient && $set_backup_transient ) {
     109                $this->send_restore_link_email_to_admin();
     110            }
     111        }
     112    }
     113
     114    /**
     115     * Send restore link email to admin.
     116     *
     117     * @since 5.3.0
     118     */
     119    private function send_restore_link_email_to_admin() {
     120        if ( ! $this->is_restore_siteurl_email_sent ) {
     121            $restore_link = add_query_arg( [
     122                'srk' => $this->generate_restore_key(),
     123            ], $this->get_old_home_value() );
     124
     125            $admin_email = get_option( 'admin_email' );
     126            /**
     127             * Filters the subject of the restore siteurl email.
     128             *
     129             * @since 5.3.0
     130             *
     131             * @param string $email_subject The subject of the restore siteurl email.
     132             */
     133            $email_subject = apply_filters( 'restore_siteurl_email_subject', __( 'Your WordPress site url was changed.' ) );
     134            /**
     135             * Filters the content body of the restore siteurl email.
     136             *
     137             * @since 5.3.0
     138             *
     139             * @param string $email_content The content body of the restore siteurl email.
     140             */
     141            $email_content = apply_filters(
     142                'restore_siteurl_email_message',
     143                __( 'You can undo this change by clicking this link ###restore_link###' )
     144            );
     145
     146            // Replace placeholder with actual restore link.
     147            $email_content = str_replace( '###restore_link###', $restore_link, $email_content );
     148
     149            $email = wp_mail( $admin_email, $email_subject, $email_content );
     150
     151            if ( $email ) {
     152                $this->is_restore_siteurl_email_sent = true;
     153            }
     154
     155            /**
     156             * Fires after the attempt to send restore link email to admin.
     157             *
     158             * @since 5.3.0
     159             *
     160             * @param boolean $email Whether the email is sent successfully.
     161             */
     162            do_action( 'send_restore_link_email', $email );
     163        }
     164    }
     165
     166    /**
     167     * Get the old 'home' option value.
     168     *
     169     * @since 5.3.0
     170     *
     171     * @return string $old_home_value Old 'home' value.
     172     */
     173    public function get_old_home_value() {
     174        if ( null !== $this->old_home_value ) {
     175            return esc_url( $this->old_home_value );
     176        }
     177
     178        $old_home_transient = get_transient( 'old_home' );
     179        if ( $old_home_transient !== false ) {
     180            $this->old_home_value = $old_home_transient;
     181        }
     182        else {
     183            $this->old_home_value = get_option( 'home' );
     184        }
     185       
     186        return esc_url( $this->old_home_value );
     187    }
     188
     189    /**
     190     * Perform the siteurl restore operation.
     191     *
     192     * If the provided restore key is invalid, the process is terminated using `wp_die()`.
     193     * Else if the restore key is valid, then it will perform the restore siteurl operation.
     194     *
     195     * If the restore siteurl operation is a success, it will perform the following.
     196     * 1. Delete the backup transients.
     197     * 2. Create a success-tracker transient named 'siteurl_restore_success'.
     198     * 3. Redirect the user to the old admin dashboard url.
     199     *
     200     * @since 5.3.0
     201     */
     202    public function perform_siteurl_restore() {
     203        if ( isset( $_GET['srk'] ) && ! empty( $_GET['srk'] ) ) {
     204            $restore_key = get_transient( 'siteurl_restore_key' );
     205
     206            if ( $_GET['srk'] === $restore_key ) {
     207                $old_siteurl = get_transient( 'old_siteurl' );
     208                $old_home        = get_transient( 'old_home' );
     209
     210                $is_restore_success = false;
     211
     212                if ( $old_siteurl ) {
     213                    $update_siteurl = update_option( 'siteurl', $old_siteurl );
     214
     215                    if ( $update_siteurl ) {
     216                        delete_transient( 'old_siteurl' );
     217
     218                        $is_restore_success = true;
     219                    }
     220                }
     221
     222                if ( $old_home ) {
     223                    $update_home = update_option( 'home', $old_home );
     224
     225                    if ( $update_home ) {
     226                        delete_transient( 'old_home' );
     227
     228                        $is_restore_success = true;
     229                    }
     230                }
     231
     232                if ( $is_restore_success ) {
     233                    delete_transient( 'siteurl_restore_key' );
     234
     235                    // Track success.
     236                    set_transient( 'siteurl_restore_success', '1', 300 );
     237
     238                    $this->success_redirect();
     239                }
     240            }
     241            else {
     242                wp_die( __( 'Restore key is invalid.' ) );
     243                exit;
     244            }
     245        }
     246    }
     247
     248    /**
     249     * Redirect the user to admin dashboard with restore siteurl success $_GET param.
     250     *
     251     * @since 5.3.0
     252     */
     253    protected function success_redirect() {
     254        $restored_site_admin_url = trailingslashit( get_option( 'home' ) ) . 'wp-admin';
     255
     256        // Success redirect url.
     257        $success_redirect_url = add_query_arg( 'srsuccess', '1', $restored_site_admin_url );
     258
     259        wp_redirect( $success_redirect_url );
     260        exit;
     261    }
     262
     263    /**
     264     * Display success notice if the restore siteurl operation is a success.
     265     *
     266     * @since 5.3.0
     267     */
     268    public function restore_siteurl_success_notice() {
     269        if ( isset( $_GET['srsuccess'] ) && '1' === $_GET['srsuccess'] ) {
     270            $restore_success = get_transient( 'siteurl_restore_success' );
     271
     272            if ( '1' === $restore_success ) {
     273                add_action( 'admin_notices', [ $this, 'restore_siteurl_success_notice__success' ] );
     274
     275                delete_transient( 'siteurl_restore_success' );
     276            }
     277        }
     278    }
     279
     280    /**
     281     * Restore siteurl success notice.
     282     *
     283     * @since 5.3.0
     284     */
     285    public function restore_siteurl_success_notice__success() {
     286        $class = 'notice notice-success';
     287        $message = __( 'Your WordPress site url was successfully restored.' );
     288
     289        printf( '<div class="%1$s"><p>%2$s</p></div>', esc_attr( $class ), esc_html( $message ) );
     290    }
     291}
     292 No newline at end of file
  • src/wp-includes/functions.php

    diff --git src/wp-includes/functions.php src/wp-includes/functions.php
    index f32e236778..6be7a39db7 100644
    function is_wp_version_compatible( $required ) { 
    73167316function is_php_version_compatible( $required ) {
    73177317        return empty( $required ) || version_compare( phpversion(), $required, '>=' );
    73187318}
     7319
     7320/**
     7321 * Access the WP Restore Siteurl instance.
     7322 *
     7323 * @since 5.3.0
     7324 *
     7325 * @return WP_Restore_Siteurl
     7326 */
     7327function wp_restore_siteurl() {
     7328        static $wp_restore_siteurl;
     7329
     7330        if ( ! $wp_restore_siteurl ) {
     7331                $wp_restore_siteurl = new WP_Restore_Siteurl();
     7332        }
     7333
     7334        return $wp_restore_siteurl;
     7335}
     7336 No newline at end of file
  • src/wp-settings.php

    diff --git src/wp-settings.php src/wp-settings.php
    index 681053a0d4..70e0965d89 100644
    require_once( ABSPATH . WPINC . '/class-wp-locale-switcher.php' ); 
    151151wp_not_installed();
    152152
    153153// Load most of WordPress.
     154require( ABSPATH . WPINC . '/class-wp-restore-siteurl.php' );
    154155require( ABSPATH . WPINC . '/class-wp-walker.php' );
    155156require( ABSPATH . WPINC . '/class-wp-ajax-response.php' );
    156157require( ABSPATH . WPINC . '/capabilities.php' );
    unset( $plugin ); 
    373374require( ABSPATH . WPINC . '/pluggable.php' );
    374375require( ABSPATH . WPINC . '/pluggable-deprecated.php' );
    375376
     377// Load the WP Restore Siteurl instance.
     378wp_restore_siteurl();
     379
    376380// Set internal encoding.
    377381wp_set_internal_encoding();
    378382
  • new file tests/phpunit/tests/restore-siteurl/restore-siteurl.php

    diff --git tests/phpunit/tests/restore-siteurl/restore-siteurl.php tests/phpunit/tests/restore-siteurl/restore-siteurl.php
    new file mode 100644
    index 0000000000..f151037c3e
    - +  
     1<?php
     2/**
     3 * @group restore-siteurl
     4 */
     5class Tests_Restore_Siteurl extends WP_UnitTestCase {
     6
     7    /**
     8     * Tracker of how many times the action `send_restore_link_email` is invoked.
     9     *
     10     * @var integer
     11     */
     12    private $test_opt_ctr = 1;
     13
     14    /**
     15     * Restore siteurl email should only be sent once.
     16     *
     17     * In this test, 'test_opt' option is added with the value of `$this->test_opt_ctr`.
     18     * Then function `$this->change_test_opt_value()` that increments the value of `test_opt`
     19     * is hooked in `send_restore_link_email` action.
     20     *
     21     * Since it is expected that the email will only be sent once, then `send_restore_link_email`
     22     * action should only be invoked once. Hence the expected value of `test_opt` value should
     23     * be 1.
     24     */
     25    public function test_restore_link_email_should_only_be_sent_once() {
     26        update_option( 'test_opt', $this->test_opt_ctr );
     27
     28        add_action( 'send_restore_link_email', array( $this, 'change_test_opt_value' ) );
     29
     30        update_option( 'home', 'http://wp.org' );
     31        update_option( 'site_url', 'http://wp.org' );
     32
     33        $test_opt = get_option( 'test_opt' );
     34
     35        $this->assertEquals( '1', $test_opt );
     36    }
     37
     38    public function change_test_opt_value( $email ) {
     39        update_option( 'test_opt', $this->test_opt_ctr );
     40        $this->test_opt_ctr += 1;
     41    }
     42
     43    public function test_updating_home_option_should_create_backup_transient() {
     44        update_option( 'home', 'http://wp.org' );
     45        $a = get_option( 'home' );
     46        $actual = get_transient( 'old_home' );
     47        $this->assertNotFalse( $actual );
     48    }
     49
     50    public function test_updating_siteurl_option_should_create_backup_transient() {
     51        update_option( 'siteurl', 'http://wp.org' );
     52        $a = get_option( 'siteurl' );
     53        $actual = get_transient( 'old_siteurl' );
     54        $this->assertNotFalse( $actual );
     55    }
     56
     57    public function test_restore_key_should_not_change_when_both_options_are_updated() {
     58        update_option( 'home', 'http://wp.org' );
     59
     60        $wp_restore_siteurl = wp_restore_siteurl();
     61
     62        $reflection = new ReflectionClass( $wp_restore_siteurl );
     63        $method     = $reflection->getMethod( 'generate_restore_key' );
     64        $method->setAccessible( true );
     65
     66        $restore_key           = $method->invokeArgs( $wp_restore_siteurl, array() );
     67        $transient_restore_key = get_transient( 'siteurl_restore_key' );
     68
     69        update_option( 'siteurl', 'http://wp.org' );
     70
     71        $this->assertEquals( $restore_key, $transient_restore_key );
     72    }
     73
     74    /**
     75     * @expectedException        WPDieException
     76     * @expectedExceptionMessage Restore key is invalid.
     77     */
     78    public function test_invalid_restore_key_should_be_blocked() {
     79        update_option( 'home', 'http://wp.org' );
     80        update_option( 'siteurl', 'http://wp.org' );
     81
     82        // Use invalid restore key.
     83        $_GET['srk'] = 'this-is-invalid.';
     84
     85        $wp_restore_siteurl = wp_restore_siteurl();
     86        $wp_restore_siteurl->perform_siteurl_restore();
     87    }
     88
     89    public function test_success_perform_siteurl_restore() {
     90        update_option( 'home', 'http://wp.org' );
     91        update_option( 'siteurl', 'http://wp.org' );
     92
     93        // Use the correct restore keys.
     94        $_GET = array();
     95        $_GET['srk'] = get_transient( 'siteurl_restore_key' );
     96
     97        // Test that success restore will perform redirection.
     98        $wp_restore_siteurl = $this->getMockBuilder( 'WP_Restore_Siteurl' )
     99                                ->setMethods( array( 'success_redirect' ) )
     100                                ->getMock();
     101        $wp_restore_siteurl->expects( $this->once() )
     102            ->method( 'success_redirect' );
     103
     104        $wp_restore_siteurl->perform_siteurl_restore();
     105
     106        $this->assertFalse( get_transient( 'old_home' ) );
     107        $this->assertFalse( get_transient( 'old_siteurl' ) );
     108        $this->assertFalse( get_transient( 'siteurl_restore_key' ) );
     109       
     110        $this->assertEquals( '1', get_transient( 'siteurl_restore_success' ) );
     111    }
     112
     113    public function test_restore_siteurl_success_notice() {
     114        update_option( 'home', 'http://wp.org' );
     115        update_option( 'siteurl', 'http://wp.org' );
     116
     117        // Use the correct restore keys.
     118        $_GET = array();
     119        $_GET['srk'] = get_transient( 'siteurl_restore_key' );
     120
     121        // Perform the siteurl restore.
     122        $wp_restore_siteurl = $this->getMockBuilder( 'WP_Restore_Siteurl' )
     123                                ->setMethods( array( 'success_redirect' ) )
     124                                ->getMock();
     125        $wp_restore_siteurl->perform_siteurl_restore();
     126
     127        // Simulate click to success url.
     128        $_GET = array();
     129        $_GET['srsuccess'] = '1';
     130        $wp_restore_siteurl->restore_siteurl_success_notice();
     131
     132        $is_success_notice_hooked = has_action( 'admin_notices', [ $wp_restore_siteurl, 'restore_siteurl_success_notice__success'] );
     133
     134        $this->assertEquals( 10, $is_success_notice_hooked );
     135
     136        $success_transient = get_transient( 'siteurl_restore_success' );
     137
     138        $this->assertFalse( $success_transient );
     139    }
     140}
     141 No newline at end of file