Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
2.55% covered (danger)
2.55%
5 / 196
7.69% covered (danger)
7.69%
1 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOMSH_Recovery_Mode_Sync
2.56% covered (danger)
2.56%
5 / 195
7.69% covered (danger)
7.69%
1 / 13
6455.55
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 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 capture_session_start
14.29% covered (danger)
14.29%
2 / 14
0.00% covered (danger)
0.00%
0 / 1
4.52
 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 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 current_session_errors
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 capture_current_fatal
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
110
 resolve_extension_for_file
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
240
 extract_errors
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
306
 resolve_extension_version
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
110
 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 + a per-extension error list are POSTed to
8 * `/sites/{blog_id}/recovery-mode-status`:
9 *
10 *   - `recovery_mode_email_last_sent`  — written by WP core each time a fatal
11 *     triggers a recovery email (rate-limited to ~1/day).
12 *   - `recovery_session_entered_at`    — wpcomsh-managed; updated on any write
13 *     to `{session_id}_paused_extensions`, i.e. when the admin enters a
14 *     recovery session by clicking the email link.
15 *   - `recovery_session_exited_at`     — wpcomsh-managed; updated on deletion
16 *     of `{session_id}_paused_extensions`, i.e. when the admin exits recovery.
17 *   - `recovery_session_errors`        — a list of `{kind, slug, version,
18 *     errno, message, file, line}` records, so wpcom can surface what fataled
19 *     rather than just that something fataled. Sourced from the live
20 *     `*_paused_extensions` option once the admin enters recovery; on the
21 *     fatal request itself (before the admin clicks the email link) the list
22 *     is populated from `error_get_last()` so the very first POST already
23 *     carries the error info. Empty once the admin exits recovery.
24 *
25 * The POST runs from a PHP shutdown function so the signal reaches wpcom even
26 * on fatal-error requests, matching the pattern used by migrate-guru-canary.
27 *
28 * @package wpcomsh
29 */
30
31use Automattic\Jetpack\Connection\Client as Jetpack_Connection_Client;
32
33/**
34 * Captures recovery-mode option writes and forwards a state snapshot to wpcom
35 * on PHP shutdown.
36 */
37class WPCOMSH_Recovery_Mode_Sync {
38
39    private const EMAIL_LAST_SENT_OPTION          = 'recovery_mode_email_last_sent';
40    private const ENTERED_AT_OPTION               = 'wpcomsh_recovery_session_entered_at';
41    private const EXITED_AT_OPTION                = 'wpcomsh_recovery_session_exited_at';
42    private const PAUSED_EXTENSIONS_OPTION_SUFFIX = '_paused_extensions';
43
44    /**
45     * Pending state snapshot. Empty until the first capture this request —
46     * its non-emptiness doubles as the "send needed" flag.
47     *
48     * @var array<string,mixed>
49     */
50    private static $payload = array();
51
52    /**
53     * Error record captured from `error_get_last()` at email-send time so the
54     * fatal-request POST carries error info before the admin enters recovery
55     * and `*_paused_extensions` exists. Null when no fatal has been captured
56     * (or when the fatal didn't come from a known plugin/theme).
57     *
58     * @var array<string,mixed>|null
59     */
60    private static $transient_fatal = null;
61
62    /**
63     * Register option-change listeners.
64     */
65    public static function init() {
66        add_action( 'add_option_' . self::EMAIL_LAST_SENT_OPTION, array( __CLASS__, 'capture_email_last_sent' ) );
67        add_action( 'update_option_' . self::EMAIL_LAST_SENT_OPTION, array( __CLASS__, 'capture_email_last_sent' ) );
68
69        // Only `added_option` signals a new recovery session. `updated_option`
70        // fires for in-session extension additions and — crucially — during
71        // exit unwinding when `WP_Paused_Extensions_Storage::delete_all()` of
72        // one type rewrites the session option with remaining entries of the
73        // other type; treating that as a new entry would clobber entered_at.
74        add_action( 'added_option', array( __CLASS__, 'capture_session_start' ), 10, 1 );
75        add_action( 'deleted_option', array( __CLASS__, 'capture_session_end' ), 10, 1 );
76    }
77
78    /**
79     * Listener for the recovery-mode email timestamp.
80     */
81    public static function capture_email_last_sent() {
82        // We're called inside WP's fatal-handler shutdown stack, so
83        // `error_get_last()` still holds the original fatal that caused WP to
84        // send the email. Snapshot it now so the snapshot below picks it up.
85        self::capture_current_fatal();
86        self::snapshot();
87        self::trace(
88            'captured email_last_sent',
89            array( 'value' => self::$payload['recovery_mode_email_last_sent'] )
90        );
91        self::send();
92    }
93
94    /**
95     * Listener for option writes that may represent entering a recovery session.
96     *
97     * @param string $option Option name.
98     */
99    public static function capture_session_start( $option ) {
100        if ( ! self::is_paused_extensions_option( $option ) ) {
101            return;
102        }
103        $now = time();
104        update_option( self::ENTERED_AT_OPTION, $now, false );
105        self::snapshot();
106        self::trace(
107            'captured session_start',
108            array(
109                'option'     => $option,
110                'entered_at' => $now,
111                'errors'     => self::$payload['recovery_session_errors'] ?? array(),
112            )
113        );
114        self::send();
115    }
116
117    /**
118     * Listener for option deletions that represent exiting a recovery session.
119     *
120     * @param string $option Option name.
121     */
122    public static function capture_session_end( $option ) {
123        if ( ! self::is_paused_extensions_option( $option ) ) {
124            return;
125        }
126        $now = time();
127        update_option( self::EXITED_AT_OPTION, $now, false );
128        self::snapshot();
129        self::trace(
130            'captured session_end',
131            array(
132                'option'    => $option,
133                'exited_at' => $now,
134            )
135        );
136        self::send();
137    }
138
139    /**
140     * POST the current state snapshot to wpcom. Called synchronously from each
141     * capture listener — we deliberately do *not* defer to a PHP shutdown
142     * function because option writes that trigger a capture often happen from
143     * inside WP's own fatal-handler shutdown callback, and shutdown callbacks
144     * registered at that point (or even earlier) are not reliably invoked on
145     * the dying request.
146     */
147    public static function send() {
148        if ( empty( self::$payload ) ) {
149            return;
150        }
151        if ( ! class_exists( Jetpack_Connection_Client::class ) ) {
152            self::trace( 'send() aborting: Jetpack Connection Client class missing' );
153            return;
154        }
155        if ( ! function_exists( '_wpcom_get_current_blog_id' ) ) {
156            self::trace( 'send() aborting: _wpcom_get_current_blog_id() not defined' );
157            return;
158        }
159
160        // The Connection Client signs requests with wp_rand() / wp_generate_password(),
161        // both defined in pluggable.php. That file is loaded late in WP's bootstrap and
162        // is often not yet available when we're called from inside WP's fatal-handler
163        // shutdown path (the exact case this feature exists to handle). Load it here.
164        if ( ! function_exists( 'wp_rand' ) && defined( 'ABSPATH' ) ) {
165            $pluggable_path = ABSPATH . 'wp-includes/pluggable.php';
166            if ( file_exists( $pluggable_path ) ) {
167                require_once $pluggable_path;
168            }
169        }
170        if ( ! function_exists( 'wp_rand' ) ) {
171            self::trace( 'send() aborting: wp_rand() unavailable' );
172            return;
173        }
174
175        try {
176            $wpcom_blog_id = _wpcom_get_current_blog_id();
177            if ( ! $wpcom_blog_id ) {
178                self::trace( 'send() aborting: blog_id is falsy', array( 'value' => $wpcom_blog_id ) );
179                return;
180            }
181
182            self::trace(
183                'posting state',
184                array(
185                    'blog_id' => $wpcom_blog_id,
186                    'payload' => self::$payload,
187                )
188            );
189
190            $response = Jetpack_Connection_Client::wpcom_json_api_request_as_blog(
191                sprintf( '/sites/%s/recovery-mode-status', $wpcom_blog_id ),
192                'v2',
193                array( 'method' => 'POST' ),
194                self::$payload,
195                'wpcom'
196            );
197
198            if ( is_wp_error( $response ) ) {
199                self::trace(
200                    'post returned WP_Error',
201                    array( 'error' => $response->get_error_message() )
202                );
203            } else {
204                $code = (int) wp_remote_retrieve_response_code( $response );
205                if ( $code < 200 || $code >= 300 ) {
206                    self::trace(
207                        'post returned non-2xx',
208                        array( 'code' => $code )
209                    );
210                }
211            }
212        } catch ( \Throwable $e ) {
213            self::trace(
214                'post threw',
215                array( 'exception' => $e->getMessage() )
216            );
217        }
218    }
219
220    /**
221     * Populate the in-memory state snapshot once per request. Errors are read
222     * from the live `*_paused_extensions` option so every capture path (email,
223     * start, end) emits a complete state — no persistence of our own needed.
224     */
225    private static function snapshot() {
226        if ( ! empty( self::$payload ) ) {
227            return;
228        }
229        self::$payload = array(
230            'recovery_mode_email_last_sent' => (int) get_option( self::EMAIL_LAST_SENT_OPTION, 0 ),
231            'recovery_session_entered_at'   => (int) get_option( self::ENTERED_AT_OPTION, 0 ),
232            'recovery_session_exited_at'    => (int) get_option( self::EXITED_AT_OPTION, 0 ),
233            'recovery_session_errors'       => self::current_session_errors(),
234        );
235    }
236
237    /**
238     * Read the active recovery session's `*_paused_extensions` option and
239     * normalize it into transportable error records. Returns an empty array
240     * when the session is gone (e.g. after admin exits) so wpcom sees the
241     * state cleared rather than stale errors.
242     *
243     * @return array<int,array<string,mixed>>
244     */
245    private static function current_session_errors() {
246        if ( function_exists( 'wp_recovery_mode' ) ) {
247            $session_id = wp_recovery_mode()->get_session_id();
248            if ( ! empty( $session_id ) ) {
249                $errors = self::extract_errors(
250                    get_option( $session_id . self::PAUSED_EXTENSIONS_OPTION_SUFFIX )
251                );
252                if ( ! empty( $errors ) ) {
253                    return $errors;
254                }
255            }
256        }
257        // No active session yet (fatal request, before admin clicks the email
258        // link) — fall back to the fatal we captured from `error_get_last()`.
259        return null !== self::$transient_fatal ? array( self::$transient_fatal ) : array();
260    }
261
262    /**
263     * Snapshot the currently-pending PHP fatal (if any) into a transportable
264     * record. Called from `capture_email_last_sent()` so we run inside WP's
265     * fatal-handler shutdown, when `error_get_last()` still holds the fatal
266     * that triggered the recovery email. No-op when there's no fatal pending
267     * or the fatal didn't originate from a known plugin/theme path.
268     */
269    private static function capture_current_fatal() {
270        if ( null !== self::$transient_fatal ) {
271            return;
272        }
273        $err = error_get_last();
274        if ( ! is_array( $err ) ) {
275            return;
276        }
277        $fatal_mask = E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR | E_RECOVERABLE_ERROR;
278        if ( ! ( (int) ( $err['type'] ?? 0 ) & $fatal_mask ) ) {
279            return;
280        }
281        $file     = (string) ( $err['file'] ?? '' );
282        $resolved = self::resolve_extension_for_file( $file );
283        if ( null === $resolved ) {
284            return;
285        }
286        list( $kind, $slug ) = $resolved;
287
288        if ( ! function_exists( 'get_plugins' ) && defined( 'ABSPATH' ) ) {
289            $plugin_admin = ABSPATH . 'wp-admin/includes/plugin.php';
290            if ( file_exists( $plugin_admin ) ) {
291                require_once $plugin_admin;
292            }
293        }
294        $plugins = function_exists( 'get_plugins' ) ? get_plugins() : array();
295
296        self::$transient_fatal = array(
297            'kind'    => $kind,
298            'slug'    => $slug,
299            'version' => self::resolve_extension_version( $kind, $slug, $plugins ),
300            'errno'   => (int) $err['type'],
301            'message' => (string) ( $err['message'] ?? '' ),
302            'file'    => '' !== $file ? basename( $file ) : '',
303            'line'    => (int) ( $err['line'] ?? 0 ),
304        );
305    }
306
307    /**
308     * Identify the plugin or theme a fatal originated from by matching the
309     * file path against the plugin and active theme directories. Returns
310     * `[kind, slug]` matching the shape WP's own recovery storage uses — the
311     * plugin slug is the first path segment under `WP_PLUGIN_DIR` (mirroring
312     * core's `WP_Recovery_Mode::get_extension_for_error()`), and the theme
313     * slug is the stylesheet/template directory name. Returns null when the
314     * file doesn't live under a known extension (core, mu-plugins, drop-ins).
315     *
316     * @param string $file Absolute path to the file that fataled.
317     * @return array{0:string,1:string}|null
318     */
319    private static function resolve_extension_for_file( $file ) {
320        if ( '' === $file || ! function_exists( 'wp_normalize_path' ) ) {
321            return null;
322        }
323        $normalized = wp_normalize_path( $file );
324
325        if ( defined( 'WP_PLUGIN_DIR' ) ) {
326            $plugin_dir = wp_normalize_path( WP_PLUGIN_DIR ) . '/';
327            if ( str_starts_with( $normalized, $plugin_dir ) ) {
328                $rel   = substr( $normalized, strlen( $plugin_dir ) );
329                $parts = explode( '/', $rel );
330                if ( '' !== $parts[0] ) {
331                    return array( 'plugin', $parts[0] );
332                }
333            }
334        }
335
336        if ( function_exists( 'get_stylesheet' ) && function_exists( 'get_stylesheet_directory' ) ) {
337            $candidates = array();
338            $stylesheet = (string) get_stylesheet();
339            if ( '' !== $stylesheet ) {
340                $candidates[ $stylesheet ] = wp_normalize_path( get_stylesheet_directory() ) . '/';
341            }
342            if ( function_exists( 'get_template' ) && function_exists( 'get_template_directory' ) ) {
343                $template = (string) get_template();
344                if ( '' !== $template && $template !== $stylesheet ) {
345                    $candidates[ $template ] = wp_normalize_path( get_template_directory() ) . '/';
346                }
347            }
348            foreach ( $candidates as $slug => $dir ) {
349                if ( str_starts_with( $normalized, $dir ) ) {
350                    return array( 'theme', $slug );
351                }
352            }
353        }
354
355        return null;
356    }
357
358    /**
359     * Normalize WP's `*_paused_extensions` option into a flat list of records
360     * suitable for transport. The option is shaped as
361     * `[ 'plugin' => [ slug => {type,file,line,message} ], 'theme' => [ ... ] ]`.
362     *
363     * Each output record carries the kind/slug/version + the captured error
364     * (file is reduced to its basename so server paths don't leak).
365     *
366     * @param mixed $paused_extensions Raw option value.
367     * @return array<int,array<string,mixed>>
368     */
369    private static function extract_errors( $paused_extensions ) {
370        if ( ! is_array( $paused_extensions ) ) {
371            return array();
372        }
373
374        // `get_plugins()` lives in wp-admin/includes/plugin.php — not loaded on
375        // front-end requests, but recovery emails fire there.
376        if ( ! function_exists( 'get_plugins' ) && defined( 'ABSPATH' ) ) {
377            $plugin_admin = ABSPATH . 'wp-admin/includes/plugin.php';
378            if ( file_exists( $plugin_admin ) ) {
379                require_once $plugin_admin;
380            }
381        }
382        $plugins = function_exists( 'get_plugins' ) ? get_plugins() : array();
383
384        $out = array();
385        foreach ( array( 'plugin', 'theme' ) as $kind ) {
386            if ( empty( $paused_extensions[ $kind ] ) || ! is_array( $paused_extensions[ $kind ] ) ) {
387                continue;
388            }
389            foreach ( $paused_extensions[ $kind ] as $slug => $error ) {
390                if ( ! is_string( $slug ) || '' === $slug || ! is_array( $error ) ) {
391                    continue;
392                }
393                $out[] = array(
394                    'kind'    => $kind,
395                    'slug'    => $slug,
396                    'version' => self::resolve_extension_version( $kind, $slug, $plugins ),
397                    'errno'   => isset( $error['type'] ) ? (int) $error['type'] : 0,
398                    'message' => isset( $error['message'] ) ? (string) $error['message'] : '',
399                    'file'    => isset( $error['file'] ) ? basename( (string) $error['file'] ) : '',
400                    'line'    => isset( $error['line'] ) ? (int) $error['line'] : 0,
401                );
402            }
403        }
404        return $out;
405    }
406
407    /**
408     * Resolve the installed version string for a paused extension.
409     *
410     * For plugins, WP keys the paused entry by the plugin's main-file path (or
411     * its dirname); we match against `get_plugins()` output. For themes,
412     * `wp_get_theme()` looks up by stylesheet directory name.
413     *
414     * @param string $kind    `plugin` or `theme`.
415     * @param string $slug    Extension slug as recorded by WP recovery mode.
416     * @param array  $plugins Cached `get_plugins()` output (plugins only).
417     * @return string Version string, or empty when not resolvable.
418     */
419    private static function resolve_extension_version( $kind, $slug, $plugins ) {
420        if ( 'plugin' === $kind ) {
421            foreach ( $plugins as $file => $data ) {
422                if ( $slug === $file || $slug === dirname( $file ) ) {
423                    return isset( $data['Version'] ) ? (string) $data['Version'] : '';
424                }
425            }
426            return '';
427        }
428        if ( 'theme' === $kind && function_exists( 'wp_get_theme' ) ) {
429            $theme = wp_get_theme( $slug );
430            if ( $theme && $theme->exists() ) {
431                return (string) $theme->get( 'Version' );
432            }
433        }
434        return '';
435    }
436
437    /**
438     * Whether the given option name is a recovery-session paused-extensions
439     * option (session-scoped, dynamically named).
440     *
441     * @param string $option Option name.
442     * @return bool
443     */
444    private static function is_paused_extensions_option( $option ) {
445        return is_string( $option ) && str_ends_with( $option, self::PAUSED_EXTENSIONS_OPTION_SUFFIX );
446    }
447
448    /**
449     * Emit a trace log to error_log when opted in via filter. Default is off.
450     *
451     * Enable on a specific site with:
452     *   add_filter( 'wpcomsh_recovery_mode_sync_logging_enabled', '__return_true' );
453     *
454     * @param string $message Trace message.
455     * @param array  $extra   Optional structured context.
456     */
457    private static function trace( $message, $extra = array() ) {
458        /**
459         * Whether to emit recovery-mode-sync trace logs to error_log.
460         *
461         * @param bool $enabled Defaults to false.
462         */
463        if ( ! apply_filters( 'wpcomsh_recovery_mode_sync_logging_enabled', false ) ) {
464            return;
465        }
466        static $request_id = null;
467        if ( $request_id === null ) {
468            $request_id = substr( md5( uniqid( '', true ) ), 0, 8 );
469        }
470        // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
471        error_log( 'wpcomsh_recovery_mode_sync[' . $request_id . ']: ' . $message . ' ' . wp_json_encode( $extra, JSON_UNESCAPED_SLASHES ) );
472    }
473}
474
475WPCOMSH_Recovery_Mode_Sync::init();