Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
5.00% covered (danger)
5.00%
5 / 100
12.50% covered (danger)
12.50%
1 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOMSH_Recovery_Mode_Sync
5.05% covered (danger)
5.05%
5 / 99
12.50% covered (danger)
12.50%
1 / 8
604.66
0.00% covered (danger)
0.00%
0 / 1
 init
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 capture_email_last_sent
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 capture_session_start
15.38% covered (danger)
15.38%
2 / 13
0.00% covered (danger)
0.00%
0 / 1
4.42
 capture_session_end
15.38% covered (danger)
15.38%
2 / 13
0.00% covered (danger)
0.00%
0 / 1
4.42
 send
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
182
 snapshot
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 is_paused_extensions_option
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 trace
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * Report WordPress recovery-mode state to wpcom via a dedicated endpoint so
4 * wpcom-side consumers can surface "needs recovery" / "in recovery" states
5 * for the site.
6 *
7 * Three timestamps are POSTed to `/sites/{blog_id}/recovery-mode-status`:
8 *
9 *   - `recovery_mode_email_last_sent`  — written by WP core each time a fatal
10 *     triggers a recovery email (rate-limited to ~1/day).
11 *   - `recovery_session_entered_at`    — wpcomsh-managed; updated on any write
12 *     to `{session_id}_paused_extensions`, i.e. when the admin enters a
13 *     recovery session by clicking the email link.
14 *   - `recovery_session_exited_at`     — wpcomsh-managed; updated on deletion
15 *     of `{session_id}_paused_extensions`, i.e. when the admin exits recovery.
16 *
17 * The POST runs from a PHP shutdown function so the signal reaches wpcom even
18 * on fatal-error requests, matching the pattern used by migrate-guru-canary.
19 *
20 * @package wpcomsh
21 */
22
23use Automattic\Jetpack\Connection\Client as Jetpack_Connection_Client;
24
25/**
26 * Captures recovery-mode option writes and forwards a state snapshot to wpcom
27 * on PHP shutdown.
28 */
29class WPCOMSH_Recovery_Mode_Sync {
30
31    private const EMAIL_LAST_SENT_OPTION          = 'recovery_mode_email_last_sent';
32    private const ENTERED_AT_OPTION               = 'wpcomsh_recovery_session_entered_at';
33    private const EXITED_AT_OPTION                = 'wpcomsh_recovery_session_exited_at';
34    private const PAUSED_EXTENSIONS_OPTION_SUFFIX = '_paused_extensions';
35
36    /**
37     * Pending state snapshot. Empty until the first capture this request —
38     * its non-emptiness doubles as the "send needed" flag.
39     *
40     * @var array<string,int>
41     */
42    private static $payload = array();
43
44    /**
45     * Register option-change listeners.
46     */
47    public static function init() {
48        add_action( 'add_option_' . self::EMAIL_LAST_SENT_OPTION, array( __CLASS__, 'capture_email_last_sent' ) );
49        add_action( 'update_option_' . self::EMAIL_LAST_SENT_OPTION, array( __CLASS__, 'capture_email_last_sent' ) );
50
51        // Only `added_option` signals a new recovery session. `updated_option`
52        // fires for in-session extension additions and — crucially — during
53        // exit unwinding when `WP_Paused_Extensions_Storage::delete_all()` of
54        // one type rewrites the session option with remaining entries of the
55        // other type; treating that as a new entry would clobber entered_at.
56        add_action( 'added_option', array( __CLASS__, 'capture_session_start' ), 10, 1 );
57        add_action( 'deleted_option', array( __CLASS__, 'capture_session_end' ), 10, 1 );
58    }
59
60    /**
61     * Listener for the recovery-mode email timestamp.
62     */
63    public static function capture_email_last_sent() {
64        self::snapshot();
65        self::trace(
66            'captured email_last_sent',
67            array( 'value' => self::$payload['recovery_mode_email_last_sent'] )
68        );
69        self::send();
70    }
71
72    /**
73     * Listener for option writes that may represent entering a recovery session.
74     *
75     * @param string $option Option name.
76     */
77    public static function capture_session_start( $option ) {
78        if ( ! self::is_paused_extensions_option( $option ) ) {
79            return;
80        }
81        $now = time();
82        update_option( self::ENTERED_AT_OPTION, $now, false );
83        self::snapshot();
84        self::trace(
85            'captured session_start',
86            array(
87                'option'     => $option,
88                'entered_at' => $now,
89            )
90        );
91        self::send();
92    }
93
94    /**
95     * Listener for option deletions that represent exiting a recovery session.
96     *
97     * @param string $option Option name.
98     */
99    public static function capture_session_end( $option ) {
100        if ( ! self::is_paused_extensions_option( $option ) ) {
101            return;
102        }
103        $now = time();
104        update_option( self::EXITED_AT_OPTION, $now, false );
105        self::snapshot();
106        self::trace(
107            'captured session_end',
108            array(
109                'option'    => $option,
110                'exited_at' => $now,
111            )
112        );
113        self::send();
114    }
115
116    /**
117     * POST the current state snapshot to wpcom. Called synchronously from each
118     * capture listener — we deliberately do *not* defer to a PHP shutdown
119     * function because option writes that trigger a capture often happen from
120     * inside WP's own fatal-handler shutdown callback, and shutdown callbacks
121     * registered at that point (or even earlier) are not reliably invoked on
122     * the dying request.
123     */
124    public static function send() {
125        if ( empty( self::$payload ) ) {
126            return;
127        }
128        if ( ! class_exists( Jetpack_Connection_Client::class ) ) {
129            self::trace( 'send() aborting: Jetpack Connection Client class missing' );
130            return;
131        }
132        if ( ! function_exists( '_wpcom_get_current_blog_id' ) ) {
133            self::trace( 'send() aborting: _wpcom_get_current_blog_id() not defined' );
134            return;
135        }
136
137        // The Connection Client signs requests with wp_rand() / wp_generate_password(),
138        // both defined in pluggable.php. That file is loaded late in WP's bootstrap and
139        // is often not yet available when we're called from inside WP's fatal-handler
140        // shutdown path (the exact case this feature exists to handle). Load it here.
141        if ( ! function_exists( 'wp_rand' ) && defined( 'ABSPATH' ) ) {
142            $pluggable_path = ABSPATH . 'wp-includes/pluggable.php';
143            if ( file_exists( $pluggable_path ) ) {
144                require_once $pluggable_path;
145            }
146        }
147        if ( ! function_exists( 'wp_rand' ) ) {
148            self::trace( 'send() aborting: wp_rand() unavailable' );
149            return;
150        }
151
152        try {
153            $wpcom_blog_id = _wpcom_get_current_blog_id();
154            if ( ! $wpcom_blog_id ) {
155                self::trace( 'send() aborting: blog_id is falsy', array( 'value' => $wpcom_blog_id ) );
156                return;
157            }
158
159            self::trace(
160                'posting state',
161                array(
162                    'blog_id' => $wpcom_blog_id,
163                    'payload' => self::$payload,
164                )
165            );
166
167            $response = Jetpack_Connection_Client::wpcom_json_api_request_as_blog(
168                sprintf( '/sites/%s/recovery-mode-status', $wpcom_blog_id ),
169                'v2',
170                array( 'method' => 'POST' ),
171                self::$payload,
172                'wpcom'
173            );
174
175            if ( is_wp_error( $response ) ) {
176                self::trace(
177                    'post returned WP_Error',
178                    array( 'error' => $response->get_error_message() )
179                );
180            } else {
181                $code = (int) wp_remote_retrieve_response_code( $response );
182                if ( $code < 200 || $code >= 300 ) {
183                    self::trace(
184                        'post returned non-2xx',
185                        array( 'code' => $code )
186                    );
187                }
188            }
189        } catch ( \Throwable $e ) {
190            self::trace(
191                'post threw',
192                array( 'exception' => $e->getMessage() )
193            );
194        }
195    }
196
197    /**
198     * Populate the in-memory state snapshot once per request.
199     */
200    private static function snapshot() {
201        if ( ! empty( self::$payload ) ) {
202            return;
203        }
204        self::$payload = array(
205            'recovery_mode_email_last_sent' => (int) get_option( self::EMAIL_LAST_SENT_OPTION, 0 ),
206            'recovery_session_entered_at'   => (int) get_option( self::ENTERED_AT_OPTION, 0 ),
207            'recovery_session_exited_at'    => (int) get_option( self::EXITED_AT_OPTION, 0 ),
208        );
209    }
210
211    /**
212     * Whether the given option name is a recovery-session paused-extensions
213     * option (session-scoped, dynamically named).
214     *
215     * @param string $option Option name.
216     * @return bool
217     */
218    private static function is_paused_extensions_option( $option ) {
219        return is_string( $option ) && str_ends_with( $option, self::PAUSED_EXTENSIONS_OPTION_SUFFIX );
220    }
221
222    /**
223     * Emit a trace log to error_log when opted in via filter. Default is off.
224     *
225     * Enable on a specific site with:
226     *   add_filter( 'wpcomsh_recovery_mode_sync_logging_enabled', '__return_true' );
227     *
228     * @param string $message Trace message.
229     * @param array  $extra   Optional structured context.
230     */
231    private static function trace( $message, $extra = array() ) {
232        /**
233         * Whether to emit recovery-mode-sync trace logs to error_log.
234         *
235         * @param bool $enabled Defaults to false.
236         */
237        if ( ! apply_filters( 'wpcomsh_recovery_mode_sync_logging_enabled', false ) ) {
238            return;
239        }
240        static $request_id = null;
241        if ( $request_id === null ) {
242            $request_id = substr( md5( uniqid( '', true ) ), 0, 8 );
243        }
244        // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
245        error_log( 'wpcomsh_recovery_mode_sync[' . $request_id . ']: ' . $message . ' ' . wp_json_encode( $extra, JSON_UNESCAPED_SLASHES ) );
246    }
247}
248
249WPCOMSH_Recovery_Mode_Sync::init();