Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.81% covered (warning)
85.81%
266 / 310
67.74% covered (warning)
67.74%
21 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 1
Error_Handler
85.81% covered (warning)
85.81%
266 / 310
67.74% covered (warning)
67.74%
21 / 31
174.85
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 get_displayable_errors
97.22% covered (success)
97.22%
35 / 36
0.00% covered (danger)
0.00%
0 / 1
8
 handle_verified_errors
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 should_allow_error_filtering
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
4.25
 jetpack_react_dashboard_error
88.89% covered (warning)
88.89%
24 / 27
0.00% covered (danger)
0.00%
0 / 1
12.20
 get_instance
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 report_error
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
6
 should_report_error
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
5.03
 store_error
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
6.01
 build_action_error_data
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
5
 build_error_array
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
5.01
 wp_error_to_array
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 send_error_to_wpcom
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 encrypt_data_to_wpcom
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
2.50
 get_user_id_from_token
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 get_stored_errors
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 get_verified_errors
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 garbage_collector
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 delete_all_errors
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 delete_all_api_errors
75.00% covered (warning)
75.00%
15 / 20
0.00% covered (danger)
0.00%
0 / 1
14.25
 delete_all_errors_and_return_unfiltered_value
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 delete_stored_errors
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 delete_verified_errors
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_error_by_nonce
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 verify_error
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 register_verify_error_endpoint
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
2
 verify_xml_rpc_error
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 generic_admin_notice_error
17.65% covered (danger)
17.65%
3 / 17
0.00% covered (danger)
0.00%
0 / 1
26.11
 check_api_response_for_errors
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
15
 has_external_filters
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 invalidate_displayable_errors_cache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * The Jetpack Connection error class file.
4 *
5 * @package automattic/jetpack-connection
6 */
7
8namespace Automattic\Jetpack\Connection;
9
10/**
11 * The Jetpack Connection Errors that handles errors
12 *
13 * This class handles the following workflow for incoming XML-RPC and REST API requests:
14 *
15 * 1. An incoming XML-RPC or REST API request with an invalid signature triggers an error
16 * 2. Applies a gate to only process each error code once an hour to avoid overflow
17 * 3. It stores the error on the database, but we don't know yet if this is a valid error, because
18 *    we can't confirm it came from WP.com.
19 * 4. It encrypts the error details and sends it to the wp.com server
20 * 5. wp.com checks it and, if valid, sends a new request back to this site using the verify_xml_rpc_error REST endpoint
21 * 6. This endpoint adds this error to the Verified errors in the database
22 * 7. Triggers a workflow depending on the error (display user an error message, do some self healing, etc.)
23 *
24 * Note: This class only handles authentication/signature errors from incoming requests to this site.
25 * Outgoing request signing issues (when this site makes requests to WP.com) are not handled here.
26 *
27 * Errors are stored in the database as options in the following format:
28 *
29 * [
30 *   $error_code => [
31 *     $user_id => [
32 *       $error_details
33 *     ]
34 *   ]
35 * ]
36 *
37 * For each error code we store a maximum of 5 errors for 5 different user ids.
38 *
39 * A user ID can be:
40 * * 0 for blog tokens
41 * * positive integer for user tokens
42 * * 'invalid' for malformed tokens
43 *
44 * Example error structure:
45 * [
46 *   'invalid_token' => [
47 *     '123' => [
48 *       'error_code' => 'invalid_token',
49 *       'user_id' => '123',
50 *       'error_message' => 'The token is invalid',
51 *       'error_data' => ['action' => 'reconnect'],
52 *       'timestamp' => 1234567890,
53 *       'nonce' => 'abc123def',
54 *       'error_type' => 'xmlrpc'
55 *     ]
56 *   ]
57 * ]
58 *
59 * @since 1.14.2
60 */
61class Error_Handler {
62
63    /**
64     * The name of the option that stores the errors
65     *
66     * @since 1.14.2
67     *
68     * @var string
69     */
70    const STORED_ERRORS_OPTION = 'jetpack_connection_xmlrpc_errors';
71
72    /**
73     * The name of the option that stores the errors
74     *
75     * @since 1.14.2
76     *
77     * @var string
78     */
79    const STORED_VERIFIED_ERRORS_OPTION = 'jetpack_connection_xmlrpc_verified_errors';
80
81    /**
82     * The prefix of the transient that controls the gate for each error code
83     *
84     * @since 1.14.2
85     *
86     * @var string
87     */
88    const ERROR_REPORTING_GATE = 'jetpack_connection_error_reporting_gate_';
89
90    /**
91     * Time in seconds a test should live in the database before being discarded
92     *
93     * @since 1.14.2
94     */
95    const ERROR_LIFE_TIME = DAY_IN_SECONDS;
96
97    /**
98     * List of known errors. Only error codes in this list will be handled
99     *
100     * @since 1.14.2
101     *
102     * @var array
103     */
104    public $known_errors = array(
105        'malformed_token',
106        'malformed_user_id',
107        'unknown_user',
108        'no_user_tokens',
109        'empty_master_user_option',
110        'no_token_for_user',
111        'token_malformed',
112        'user_id_mismatch',
113        'no_possible_tokens',
114        'no_valid_user_token',
115        'no_valid_blog_token',
116        'unknown_token',
117        'could_not_sign',
118        'invalid_scheme',
119        'invalid_secret',
120        'invalid_token',
121        'token_mismatch',
122        'invalid_body',
123        'invalid_signature',
124        'invalid_body_hash',
125        'invalid_nonce',
126        'signature_mismatch',
127        'invalid_connection_owner',
128    );
129
130    /**
131     * Holds the instance of this singleton class
132     *
133     * @since 1.14.2
134     *
135     * @var Error_Handler $instance
136     */
137    public static $instance = null;
138
139    /**
140     * Cached displayable errors to avoid duplicate processing
141     *
142     * @since 6.13.10
143     *
144     * @var array|null
145     */
146    private $cached_displayable_errors = null;
147
148    /**
149     * Initialize instance, hooks and load verified errors handlers
150     *
151     * @since 1.14.2
152     */
153    private function __construct() {
154        defined( 'JETPACK__ERRORS_PUBLIC_KEY' ) || define( 'JETPACK__ERRORS_PUBLIC_KEY', 'KdZY80axKX+nWzfrOcizf0jqiFHnrWCl9X8yuaClKgM=' );
155
156        add_action( 'rest_api_init', array( $this, 'register_verify_error_endpoint' ) );
157
158        // Handle verified errors on admin pages.
159        add_action( 'admin_init', array( $this, 'handle_verified_errors' ) );
160
161        // If the site gets reconnected, clear errors.
162        add_action( 'jetpack_site_registered', array( $this, 'delete_all_errors' ) );
163        add_action( 'jetpack_get_site_data_success', array( $this, 'delete_all_api_errors' ) );
164        add_filter( 'jetpack_connection_disconnect_site_wpcom', array( $this, 'delete_all_errors_and_return_unfiltered_value' ) );
165        add_filter( 'jetpack_connection_delete_all_tokens', array( $this, 'delete_all_errors_and_return_unfiltered_value' ) );
166        add_action( 'jetpack_unlinked_user', array( $this, 'delete_all_errors' ) );
167        add_action( 'jetpack_updated_user_token', array( $this, 'delete_all_errors' ) );
168    }
169
170    /**
171     * Gets displayable errors with predefined structure and optional filtering.
172     *
173     * This method returns a hierarchical array of errors (error_code => user_id => error_details)
174     * that can be safely displayed in My Jetpack and other UI components. It includes
175     * predefined error messages and actions, with optional filtering for specific sites.
176     * Only processes a limited set of error codes that are meant to be displayed to users.
177     *
178     * @since 6.13.10
179     *
180     * @return array Array of displayable errors with hierarchical structure.
181     *               Example:
182     *               [
183     *                 'invalid_token' => [
184     *                   '123' => [
185     *                     'error_code' => 'invalid_token',
186     *                     'user_id' => '123',
187     *                     'error_message' => 'Your connection with WordPress.com seems to be broken...',
188     *                     'error_data' => ['action' => 'reconnect'],
189     *                     'timestamp' => 1234567890,
190     *                     'nonce' => 'abc123def',
191     *                     'error_type' => 'xmlrpc'
192     *                   ]
193     *                 ]
194     *               ]
195     */
196    public function get_displayable_errors() {
197        // Check if we have cached result AND no filters are applied
198        if ( $this->cached_displayable_errors !== null && ! $this->has_external_filters() ) {
199            return $this->cached_displayable_errors;
200        }
201
202        $verified_errors    = $this->get_verified_errors();
203        $displayable_errors = array();
204
205        // Only process error codes that are meant to be displayed to users
206        $displayable_error_codes = array(
207            'malformed_token',
208            'token_malformed',
209            'no_possible_tokens',
210            'no_valid_user_token',
211            'no_valid_blog_token',
212            'unknown_token',
213            'could_not_sign',
214            'invalid_token',
215            'token_mismatch',
216            'invalid_signature',
217            'signature_mismatch',
218            'no_user_tokens',
219            'no_token_for_user',
220            'invalid_connection_owner',
221        );
222
223        foreach ( $verified_errors as $error_code => $users ) {
224            // Skip error codes that are not meant to be displayed
225            if ( ! in_array( $error_code, $displayable_error_codes, true ) ) {
226                continue;
227            }
228
229            $displayable_errors[ $error_code ] = array();
230
231            foreach ( $users as $user_id => $error ) {
232                // Override other error messages with default display message
233                $displayable_errors[ $error_code ][ $user_id ] = array_merge(
234                    $error,
235                    array(
236                        'error_message' => __( "Your connection with WordPress.com seems to be broken. If you're experiencing issues, please try reconnecting.", 'jetpack-connection' ),
237                    )
238                );
239            }
240        }
241
242        /**
243         * Filter displayable connection errors to allow customization of error messages and actions.
244         *
245         * This filter allows sites to customize how connection errors are displayed,
246         * including modifying error messages, actions, and data. Access to this filter
247         * is controlled by should_allow_error_filtering().
248         *
249         * @since 6.12.0
250         *
251         * @param array $displayable_errors Array of displayable errors with hierarchical structure.
252         * @param array $verified_errors    Array of raw verified errors from the database.
253         */
254        if ( $this->should_allow_error_filtering() ) {
255            $displayable_errors = apply_filters( 'jetpack_connection_get_verified_errors', $displayable_errors, $verified_errors );
256        }
257
258        // Only cache if no external filters are applied
259        if ( ! $this->has_external_filters() ) {
260            $this->cached_displayable_errors = $displayable_errors;
261        }
262
263        return $displayable_errors;
264    }
265
266    /**
267     * Sets up hooks for displaying verified errors on admin pages.
268     *
269     * This method is hooked into 'admin_init'. It retrieves displayable errors
270     * and, if any exist, sets up the necessary action and filter hooks to display
271     * them in admin notices and the React dashboard.
272     *
273     * @since 1.14.2
274     */
275    public function handle_verified_errors() {
276        $displayable_errors = $this->get_displayable_errors();
277
278        // If there are any displayable errors, set up the hooks for displaying them in React dashboard and admin notices.
279        if ( ! empty( $displayable_errors ) ) {
280            add_action( 'admin_notices', array( $this, 'generic_admin_notice_error' ) );
281            add_filter( 'react_connection_errors_initial_state', array( $this, 'jetpack_react_dashboard_error' ), 10, 1 );
282        }
283    }
284
285    /**
286     * Determines whether error filtering should be allowed.
287     *
288     * This method controls access to the jetpack_connection_displayable_errors filter.
289     * Currently, only WoA sites are allowed to use this filter.
290     *
291     * @since 6.13.10
292     *
293     * @return bool True if error filtering should be allowed, false otherwise.
294     */
295    protected function should_allow_error_filtering() {
296        $host = new \Automattic\Jetpack\Status\Host();
297        if ( $host->is_woa_site() || $host->is_vip_site() || $host->is_newspack_site() ) {
298            return true;
299        }
300
301        return false;
302    }
303
304    /**
305     * Provides displayable connection errors for the React dashboard in a flat array format.
306     *
307     * This method transforms the hierarchical displayable_errors structure into the flat format
308     * expected by the React dashboard. It's used as a filter for 'react_connection_errors_initial_state'.
309     * Returns only the first error to avoid overwhelming the user with multiple error messages.
310     *
311     * @since 8.9.0
312     *
313     * @param array $errors Existing errors from other filters (unused but required for filter signature).
314     * @return array Array containing only the first displayable error for the React dashboard.
315     *               Example:
316     *               [
317     *                 [
318     *                   'code' => 'connection_error',
319     *                   'message' => 'Your connection with WordPress.com seems to be broken...',
320     *                   'action' => 'reconnect',
321     *                   'data' => [
322     *                     'api_error_code' => 'invalid_token',
323     *                     'action' => 'reconnect'
324     *                   ]
325     *                 ]
326     *               ]
327     */
328    public function jetpack_react_dashboard_error( $errors ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
329        $displayable_errors = $this->get_displayable_errors();
330
331        // Get the first error only
332        $first_error_code = array_key_first( $displayable_errors );
333        if ( ! $first_error_code ) {
334            return array(); // No errors
335        }
336
337        $first_user_errors = $displayable_errors[ $first_error_code ];
338        if ( ! is_array( $first_user_errors ) || empty( $first_user_errors ) ) {
339            return array(); // Invalid error structure
340        }
341
342        $first_error = reset( $first_user_errors );
343
344        // Validate error structure
345        if ( ! is_array( $first_error ) || ! isset( $first_error['error_message'] ) ) {
346            return array(); // Invalid error structure
347        }
348
349        // Determine the action - use the one from error_data if available, otherwise default to 'reconnect'
350        $action = 'reconnect'; // Default action for connection errors
351        if ( isset( $first_error['error_data']['action'] ) && is_string( $first_error['error_data']['action'] ) ) {
352            $action = $first_error['error_data']['action'];
353        }
354
355        // Safely merge error data, ensuring we don't overwrite critical fields
356        $error_data = isset( $first_error['error_data'] ) && is_array( $first_error['error_data'] ) ? $first_error['error_data'] : array();
357
358        // Build the data array with safe merging
359        $dashboard_data = array( 'api_error_code' => $first_error_code );
360
361        // Add error_data fields, but be careful not to overwrite api_error_code
362        foreach ( $error_data as $key => $value ) {
363            if ( 'api_error_code' !== $key ) {
364                $dashboard_data[ $key ] = $value;
365            }
366        }
367
368        $dashboard_error = array(
369            array(
370                'code'    => 'connection_error',
371                'message' => $first_error['error_message'],
372                'action'  => $action,
373                'data'    => $dashboard_data,
374            ),
375        );
376
377        return $dashboard_error;
378    }
379
380    /**
381     * Gets the instance of this singleton class
382     *
383     * @since 1.14.2
384     *
385     * @return Error_Handler $instance
386     */
387    public static function get_instance() {
388        if ( self::$instance === null ) {
389            self::$instance = new self();
390        }
391        return self::$instance;
392    }
393
394    /**
395     * Keep track of a connection error that was encountered
396     *
397     * @param \WP_Error $error  The error object.
398     * @param boolean   $force  Force the report, even if should_report_error is false.
399     * @param boolean   $skip_wpcom_verification Set to 'true' to verify the error locally and skip the WP.com verification.
400     *
401     * @return void
402     * @since 1.14.2
403     */
404    public function report_error( \WP_Error $error, $force = false, $skip_wpcom_verification = false ) {
405        if ( in_array( $error->get_error_code(), $this->known_errors, true ) && ( $this->should_report_error( $error ) || $force ) ) {
406            $stored_error = $this->store_error( $error );
407            if ( $stored_error ) {
408                $skip_wpcom_verification ? $this->verify_error( $stored_error ) : $this->send_error_to_wpcom( $stored_error );
409            }
410        }
411    }
412
413    /**
414     * Checks the status of the gate
415     *
416     * This protects the site (and WPCOM) against over loads.
417     *
418     * @since 1.14.2
419     *
420     * @param \WP_Error $error the error object.
421     * @return boolean $should_report True if gate is open and the error should be reported.
422     */
423    public function should_report_error( \WP_Error $error ) {
424        if ( defined( '\\JETPACK_DEV_DEBUG' ) && constant( '\\JETPACK_DEV_DEBUG' ) ) {
425            return true;
426        }
427
428        /**
429         * Whether to bypass the gate for the error handling
430         *
431         * By default, we only process errors once an hour for each error code.
432         * This is done to avoid overflows. If you need to disable this gate, you can set this variable to true.
433         *
434         * This filter is useful for unit testing
435         *
436         * @since 1.14.2
437         *
438         * @param boolean $bypass_gate whether to bypass the gate. Default is false, do not bypass.
439         */
440        $bypass_gate = apply_filters( 'jetpack_connection_bypass_error_reporting_gate', false );
441        if ( true === $bypass_gate ) {
442            return true;
443        }
444
445        $transient = self::ERROR_REPORTING_GATE . $error->get_error_code();
446
447        if ( get_transient( $transient ) ) {
448            return false;
449        }
450
451        set_transient( $transient, true, HOUR_IN_SECONDS );
452        return true;
453    }
454
455    /**
456     * Stores the error in the database so we know there is an issue and can inform the user
457     *
458     * @since 1.14.2
459     *
460     * @param \WP_Error $error the error object.
461     * @return boolean|array False if stored errors were not updated and the error array if it was successfully stored.
462     */
463    public function store_error( \WP_Error $error ) {
464
465        $stored_errors = $this->get_stored_errors();
466        $error_array   = $this->wp_error_to_array( $error );
467        $error_code    = $error->get_error_code();
468        $user_id       = $error_array['user_id'];
469
470        if ( ! isset( $stored_errors[ $error_code ] ) || ! is_array( $stored_errors[ $error_code ] ) ) {
471            $stored_errors[ $error_code ] = array();
472        }
473
474        $stored_errors[ $error_code ][ $user_id ] = $error_array;
475
476        // Let's store a maximum of 5 different user ids for each error code.
477        $error_code_count = is_countable( $stored_errors[ $error_code ] ) ? count( $stored_errors[ $error_code ] ) : 0;
478        if ( $error_code_count > 5 ) {
479            // array_shift will destroy keys here because they are numeric, so manually remove first item.
480            $keys = array_keys( $stored_errors[ $error_code ] );
481            unset( $stored_errors[ $error_code ][ $keys[0] ] );
482        }
483
484        if ( update_option( self::STORED_ERRORS_OPTION, $stored_errors ) ) {
485            return $error_array;
486        }
487
488        return false;
489    }
490
491    /**
492     * Builds action error data for generic JavaScript components.
493     *
494     * This helper method creates standardized error_data arrays that work with the generic
495     * JavaScript error handling components. External plugins (like wpcomsh) can use this
496     * to ensure their error structures are compatible.
497     *
498     * @since 6.16.0
499     *
500     * @param array $args Action configuration arguments - only non-empty values will be included.
501     * @return array Standardized error_data array for JavaScript components.
502     */
503    public function build_action_error_data( array $args = array() ) {
504        // Set default values for variants
505        $args = wp_parse_args(
506            $args,
507            array(
508                'action_variant'           => 'primary',
509                'secondary_action_variant' => 'secondary',
510            )
511        );
512
513        // Start with core data
514        $error_data = array(
515            'blog_id' => \Jetpack_Options::get_option( 'id' ),
516        );
517
518        // Validate variant values
519        $valid_variants = array( 'primary', 'secondary' );
520        if ( ! in_array( $args['action_variant'], $valid_variants, true ) ) {
521            $args['action_variant'] = 'primary';
522        }
523        if ( ! in_array( $args['secondary_action_variant'], $valid_variants, true ) ) {
524            $args['secondary_action_variant'] = 'secondary';
525        }
526
527        // Merge extra_data first, then regular args (so args take precedence)
528        if ( ! empty( $args['extra_data'] ) && is_array( $args['extra_data'] ) ) {
529            $error_data = array_merge( $error_data, $args['extra_data'] );
530            unset( $args['extra_data'] ); // Remove from args to avoid duplication
531        }
532
533        // Filter out empty values and merge with error_data
534        $filtered_args = array_filter(
535            $args,
536            function ( $value ) {
537                return ! empty( $value );
538            }
539        );
540
541        return array_merge( $error_data, $filtered_args );
542    }
543
544    /**
545     * Builds a standardized error array for the connection error system.
546     *
547     * This method creates a consistent error array structure that can be used
548     * by both internal error handling and external plugins/customizations.
549     *
550     * @since 1.14.2
551     *
552     * @param string $error_code    The error code identifier.
553     * @param string $error_message The human-readable error message.
554     * @param array  $error_data    Additional error data (optional).
555     * @param string $user_id       The user ID associated with the error (optional).
556     * @param string $error_type    The type of error (optional).
557     * @return array|false The standardized error array or false on failure.
558     *                     Example successful return:
559     *                     [
560     *                       'error_code' => 'invalid_token',
561     *                       'user_id' => '123',
562     *                       'error_message' => 'The token is invalid',
563     *                       'error_data' => ['action' => 'reconnect'],
564     *                       'timestamp' => 1234567890,
565     *                       'nonce' => 'abc123def',
566     *                       'error_type' => 'xmlrpc'
567     *                     ]
568     */
569    public function build_error_array( string $error_code, string $error_message, array $error_data = array(), $user_id = '0', string $error_type = '' ) {
570        // Validate required parameters
571        if ( empty( $error_code ) || empty( $error_message ) ) {
572            return false;
573        }
574
575        // Validate user_id is a string or integer
576        if ( ! is_string( $user_id ) && ! is_int( $user_id ) ) {
577            return false;
578        }
579
580        return array(
581            'error_code'    => $error_code,
582            'user_id'       => $user_id,
583            'error_message' => $error_message,
584            'error_data'    => $error_data,
585            'timestamp'     => time(),
586            'nonce'         => wp_generate_password( 10, false ),
587            'error_type'    => $error_type,
588        );
589    }
590
591    /**
592     * Converts a WP_Error object in the array representation we store in the database
593     *
594     * @since 1.14.2
595     *
596     * @param \WP_Error $error the error object.
597     * @return boolean|array False if error is invalid or the error array
598     */
599    public function wp_error_to_array( \WP_Error $error ) {
600
601        $data = $error->get_error_data();
602
603        if ( ! isset( $data['signature_details'] ) || ! is_array( $data['signature_details'] ) ) {
604            return false;
605        }
606
607        $signature_details = $data['signature_details'];
608
609        if ( ! isset( $signature_details['token'] ) ) {
610            return false;
611        }
612
613        $user_id = $this->get_user_id_from_token( $signature_details['token'] );
614
615        return $this->build_error_array(
616            $error->get_error_code(),
617            $error->get_error_message(),
618            $signature_details,
619            $user_id,
620            empty( $data['error_type'] ) ? '' : $data['error_type']
621        );
622    }
623
624    /**
625     * Sends the error to WP.com to be verified
626     *
627     * @since 1.14.2
628     *
629     * @param array $error_array The array representation of the error as it is stored in the database.
630     * @return bool
631     */
632    public function send_error_to_wpcom( $error_array ) {
633
634        $blog_id = \Jetpack_Options::get_option( 'id' );
635
636        $encrypted_data = $this->encrypt_data_to_wpcom( $error_array );
637
638        if ( false === $encrypted_data ) {
639            return false;
640        }
641
642        $args = array(
643            'body' => array(
644                'error_data' => $encrypted_data,
645            ),
646        );
647
648        // send encrypted data to WP.com Public-API v2.
649        wp_remote_post( "https://public-api.wordpress.com/wpcom/v2/sites/{$blog_id}/jetpack-report-error/", $args );
650        return true;
651    }
652
653    /**
654     * Encrypt data to be sent over to WP.com
655     *
656     * @since 1.14.2
657     *
658     * @param array|string $data the data to be encoded.
659     * @return boolean|string The encoded string on success, false on failure
660     */
661    public function encrypt_data_to_wpcom( $data ) {
662
663        try {
664            // phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
665            // phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
666            $encrypted_data = base64_encode( sodium_crypto_box_seal( wp_json_encode( $data, JSON_UNESCAPED_SLASHES ), base64_decode( JETPACK__ERRORS_PUBLIC_KEY ) ) );
667            // phpcs:enable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
668            // phpcs:enable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
669        } catch ( \SodiumException $e ) {
670            // error encrypting data.
671            return false;
672        }
673
674        return $encrypted_data;
675    }
676
677    /**
678     * Extracts the user ID from a token
679     *
680     * @since 1.14.2
681     *
682     * @param string $token the token used to make the request.
683     * @return string $the user id or `invalid` if user id not present.
684     */
685    public function get_user_id_from_token( $token ) {
686        $user_id = 'invalid';
687
688        if ( $token ) {
689            $parsed_token = explode( ':', wp_unslash( $token ) );
690
691            if ( isset( $parsed_token[2] ) && ctype_digit( $parsed_token[2] ) ) {
692                $user_id = $parsed_token[2];
693            }
694        }
695
696        return $user_id;
697    }
698
699    /**
700     * Gets the reported errors stored in the database
701     *
702     * @since 1.14.2
703     *
704     * @return array $errors
705     */
706    public function get_stored_errors() {
707
708        $stored_errors = get_option( self::STORED_ERRORS_OPTION );
709
710        if ( ! is_array( $stored_errors ) ) {
711            $stored_errors = array();
712        }
713
714        $stored_errors = $this->garbage_collector( $stored_errors );
715
716        return $stored_errors;
717    }
718
719    /**
720     * Gets the verified errors stored in the database.
721     *
722     * This method retrieves only the errors that are actually stored in the database,
723     * without applying any filters that might inject additional errors. This is used
724     * internally by methods that need to modify and store the verified errors back
725     * to the database to prevent accidentally persisting filtered/injected errors.
726     *
727     * @since 1.14.2
728     *
729     * @return array $errors
730     */
731    public function get_verified_errors() {
732        $verified_errors = get_option( self::STORED_VERIFIED_ERRORS_OPTION );
733
734        if ( ! is_array( $verified_errors ) ) {
735            $verified_errors = array();
736        }
737
738        $verified_errors = $this->garbage_collector( $verified_errors );
739
740        return $verified_errors;
741    }
742
743    /**
744     * Removes expired errors from the array
745     *
746     * This method is called by get_stored_errors and get_verified errors and filters their result
747     * Whenever a new error is stored to the database or verified, this will be triggered and the
748     * expired error will be permanently removed from the database
749     *
750     * @since 1.14.2
751     *
752     * @param array $errors array of errors as stored in the database.
753     * @return array
754     */
755    private function garbage_collector( $errors ) {
756        foreach ( $errors as $error_code => $users ) {
757            foreach ( $users as $user_id => $error ) {
758                if ( empty( $error['timestamp'] ) || self::ERROR_LIFE_TIME < time() - (int) $error['timestamp'] ) {
759                    unset( $errors[ $error_code ][ $user_id ] );
760                }
761            }
762        }
763        // Clear empty error codes.
764        $errors = array_filter(
765            $errors,
766            function ( $user_errors ) {
767                return ! empty( $user_errors );
768            }
769        );
770        return $errors;
771    }
772
773    /**
774     * Delete all stored and verified errors from the database
775     *
776     * @since 1.14.2
777     *
778     * @return void
779     */
780    public function delete_all_errors() {
781        $this->delete_stored_errors();
782        $this->delete_verified_errors();
783
784        // Invalidate cache since we deleted all errors
785        $this->invalidate_displayable_errors_cache();
786    }
787
788    /**
789     * Delete all stored and verified API errors from the database, leave the non-API errors intact.
790     *
791     * @since 1.54.0
792     *
793     * @return void
794     */
795    public function delete_all_api_errors() {
796        $type_filter = function ( $errors ) {
797            if ( is_array( $errors ) ) {
798                foreach ( $errors as $key => $error ) {
799                    if ( ! empty( $error['error_type'] ) && in_array( $error['error_type'], array( 'xmlrpc', 'rest' ), true ) ) {
800                        unset( $errors[ $key ] );
801                    }
802                }
803            }
804
805            return count( $errors ) ? $errors : null;
806        };
807
808        $stored_errors = $this->get_stored_errors();
809        if ( is_array( $stored_errors ) && count( $stored_errors ) ) {
810            $stored_errors = array_filter( array_map( $type_filter, $stored_errors ) );
811            if ( count( $stored_errors ) ) {
812                update_option( static::STORED_ERRORS_OPTION, $stored_errors );
813            } else {
814                delete_option( static::STORED_ERRORS_OPTION );
815            }
816        }
817
818        $verified_errors = $this->get_verified_errors();
819        if ( is_array( $verified_errors ) && count( $verified_errors ) ) {
820            $verified_errors = array_filter( array_map( $type_filter, $verified_errors ) );
821            if ( count( $verified_errors ) ) {
822                update_option( static::STORED_VERIFIED_ERRORS_OPTION, $verified_errors );
823            } else {
824                delete_option( static::STORED_VERIFIED_ERRORS_OPTION );
825            }
826        }
827
828        // Invalidate cache since we may have deleted verified errors
829        $this->invalidate_displayable_errors_cache();
830    }
831
832    /**
833     * Delete all stored and verified errors from the database and returns unfiltered value
834     *
835     * This is used to hook into a couple of filters that expect true to not short circuit the disconnection flow
836     *
837     * @since 8.9.0
838     *
839     * @param mixed $check The input sent by the filter.
840     * @return boolean
841     */
842    public function delete_all_errors_and_return_unfiltered_value( $check ) {
843        $this->delete_all_errors();
844        return $check;
845    }
846
847    /**
848     * Delete the reported errors stored in the database
849     *
850     * @since 1.14.2
851     *
852     * @return boolean True, if option is successfully deleted. False on failure.
853     */
854    public function delete_stored_errors() {
855        return delete_option( self::STORED_ERRORS_OPTION );
856    }
857
858    /**
859     * Delete the verified errors stored in the database
860     *
861     * @since 1.14.2
862     *
863     * @return boolean True, if option is successfully deleted. False on failure.
864     */
865    public function delete_verified_errors() {
866        return delete_option( self::STORED_VERIFIED_ERRORS_OPTION );
867    }
868
869    /**
870     * Gets an error based on the nonce
871     *
872     * Receives a nonce and finds the related error.
873     *
874     * @since 1.14.2
875     *
876     * @param string $nonce The nonce created for the error we want to get.
877     * @return null|array Returns the error array representation or null if error not found.
878     */
879    public function get_error_by_nonce( $nonce ) {
880        $errors = $this->get_stored_errors();
881        foreach ( $errors as $user_group ) {
882            foreach ( $user_group as $error ) {
883                if ( $error['nonce'] === $nonce ) {
884                    return $error;
885                }
886            }
887        }
888        return null;
889    }
890
891    /**
892     * Adds an error to the verified error list
893     *
894     * @since 1.14.2
895     *
896     * @param array $error The error array, as it was saved in the unverified errors list.
897     * @return void
898     */
899    public function verify_error( $error ) {
900
901        $verified_errors = $this->get_verified_errors();
902        $error_code      = $error['error_code'];
903        $user_id         = $error['user_id'];
904
905        if ( ! isset( $verified_errors[ $error_code ] ) ) {
906            $verified_errors[ $error_code ] = array();
907        }
908
909        $verified_errors[ $error_code ][ $user_id ] = $error;
910
911        update_option( self::STORED_VERIFIED_ERRORS_OPTION, $verified_errors );
912
913        // Invalidate cache since we added a new verified error
914        $this->invalidate_displayable_errors_cache();
915    }
916
917    /**
918     * Register REST API end point for error handling.
919     *
920     * @since 1.14.2
921     *
922     * @return void
923     */
924    public function register_verify_error_endpoint() {
925        register_rest_route(
926            'jetpack/v4',
927            '/verify_xmlrpc_error',
928            array(
929                'methods'             => \WP_REST_Server::CREATABLE,
930                'callback'            => array( $this, 'verify_xml_rpc_error' ),
931                'permission_callback' => '__return_true',
932                'args'                => array(
933                    'nonce' => array(
934                        'required' => true,
935                        'type'     => 'string',
936                    ),
937                ),
938            )
939        );
940    }
941
942    /**
943     * Handles verification that a xml rpc error is legit and came from WordPres.com
944     *
945     * @since 1.14.2
946     *
947     * @param \WP_REST_Request $request The request sent to the WP REST API.
948     *
949     * @return boolean
950     */
951    public function verify_xml_rpc_error( \WP_REST_Request $request ) {
952        $error = $this->get_error_by_nonce( $request['nonce'] );
953
954        if ( $error ) {
955            $this->verify_error( $error );
956            return new \WP_REST_Response( true, 200 );
957        }
958
959        return new \WP_REST_Response( false, 200 );
960    }
961
962    /**
963     * Prints a generic error notice for all connection errors
964     *
965     * @since 8.9.0
966     *
967     * @return void
968     */
969    public function generic_admin_notice_error() {
970        // do not add admin notice to the jetpack dashboard.
971        global $pagenow;
972        if ( 'admin.php' === $pagenow || isset( $_GET['page'] ) && 'jetpack' === $_GET['page'] ) { // phpcs:ignore
973            return;
974        }
975
976        if ( ! current_user_can( 'jetpack_connect' ) ) {
977            return;
978        }
979
980        /**
981         * Filters the message to be displayed in the admin notices area when there's a connection error.
982         *
983         * By default  we don't display any errors.
984         *
985         * Return an empty value to disable the message.
986         *
987         * @since 8.9.0
988         *
989         * @param string $message The error message.
990         * @param array  $errors The array of errors. See Automattic\Jetpack\Connection\Error_Handler for details on the array structure.
991         */
992        $message = apply_filters( 'jetpack_connection_error_notice_message', '', $this->get_displayable_errors() );
993
994        /**
995         * Fires inside the admin_notices hook just before displaying the error message for a broken connection.
996         *
997         * If you want to disable the default message from being displayed, return an empty value in the jetpack_connection_error_notice_message filter.
998         *
999         * @since 8.9.0
1000         *
1001         * @param array $errors The array of errors. See Automattic\Jetpack\Connection\Error_Handler for details on the array structure.
1002         */
1003        do_action( 'jetpack_connection_error_notice', $this->get_displayable_errors() );
1004
1005        if ( empty( $message ) ) {
1006            return;
1007        }
1008
1009        wp_admin_notice(
1010            esc_html( $message ),
1011            array(
1012                'type'               => 'error',
1013                'dismissible'        => true,
1014                'additional_classes' => array( 'jetpack-message', 'jp-connect' ),
1015                'attributes'         => array( 'style' => 'display:block !important;' ),
1016            )
1017        );
1018    }
1019
1020    /**
1021     * Check REST API response for errors, and report them to WP.com if needed.
1022     *
1023     * @see wp_remote_request() For more information on the $http_response array format.
1024     * @param array|\WP_Error $http_response The response or WP_Error on failure.
1025     * @param array           $auth_data Auth data, allowed keys: `token`, `timestamp`, `nonce`, `body-hash`.
1026     * @param string          $url Request URL.
1027     * @param string          $method Request method.
1028     * @param string          $error_type The source of an error: 'xmlrpc' or 'rest'.
1029     *
1030     * @return void
1031     */
1032    public function check_api_response_for_errors( $http_response, $auth_data, $url, $method, $error_type ) {
1033        if ( 200 === wp_remote_retrieve_response_code( $http_response ) || ! is_array( $auth_data ) || ! $url || ! $method ) {
1034            return;
1035        }
1036
1037        $body_raw = wp_remote_retrieve_body( $http_response );
1038        if ( ! $body_raw ) {
1039            return;
1040        }
1041
1042        $body = json_decode( $body_raw, true );
1043        if ( empty( $body['error'] ) || ( ! is_string( $body['error'] ) && ! is_int( $body['error'] ) ) ) {
1044            return;
1045        }
1046
1047        $error = new \WP_Error(
1048            $body['error'],
1049            empty( $body['message'] ) ? '' : $body['message'],
1050            array(
1051                'signature_details' => array(
1052                    'token'     => empty( $auth_data['token'] ) ? '' : $auth_data['token'],
1053                    'timestamp' => empty( $auth_data['timestamp'] ) ? '' : $auth_data['timestamp'],
1054                    'nonce'     => empty( $auth_data['nonce'] ) ? '' : $auth_data['nonce'],
1055                    'body_hash' => empty( $auth_data['body_hash'] ) ? '' : $auth_data['body_hash'],
1056                    'method'    => $method,
1057                    'url'       => $url,
1058                ),
1059                'error_type'        => in_array( $error_type, array( 'xmlrpc', 'rest' ), true ) ? $error_type : '',
1060            )
1061        );
1062
1063        $this->report_error( $error, false, true );
1064    }
1065
1066    /**
1067     * Determines whether external filters are applied to the get_displayable_errors method.
1068     *
1069     * @since 6.13.10
1070     *
1071     * @return bool True if external filters are applied, false otherwise.
1072     */
1073    private function has_external_filters() {
1074        return has_filter( 'jetpack_connection_get_verified_errors' ) &&
1075            $this->should_allow_error_filtering();
1076    }
1077
1078    /**
1079     * Invalidates the cached displayable errors
1080     *
1081     * @since 6.13.10
1082     *
1083     * @return void
1084     */
1085    private function invalidate_displayable_errors_cache() {
1086        $this->cached_displayable_errors = null;
1087    }
1088}