PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.1-beta.3
Jetpack – WP Security, Backup, Speed, & Growth v16.1-beta.3
16.1.2 16.2-a.1 16.1.1 16.1 16.1-beta 16.1-beta.2 16.1-beta.3 16.1-a.5 16.1-a.3 16.0.1 16.1-a.1 16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / jetpack_vendor / automattic / jetpack-connection / src / class-error-handler.php
jetpack / jetpack_vendor / automattic / jetpack-connection / src Last commit date
abilities 2 months ago connectors 1 month ago health 1 month ago identity-crisis 1 month ago sso 1 month ago traits 9 months ago webhooks 9 months ago class-authorize-json-api.php 1 month ago class-client.php 8 months ago class-connection-assets.php 1 year ago class-connection-notice.php 8 months ago class-error-handler.php 2 weeks ago class-external-storage.php 4 months ago class-heartbeat.php 1 month ago class-initial-state.php 4 weeks ago class-manager.php 2 weeks ago class-nonce-handler.php 9 months ago class-package-version-tracker.php 1 month ago class-package-version.php 2 weeks ago class-partner-coupon.php 2 months ago class-partner.php 2 years ago class-plugin-storage.php 8 months ago class-plugin.php 9 months ago class-rest-authentication.php 9 months ago class-rest-connector.php 4 weeks ago class-secrets.php 9 months ago class-server-sandbox.php 2 months ago class-site-health.php 2 months ago class-terms-of-service.php 2 years ago class-tokens-locks.php 9 months ago class-tokens.php 9 months ago class-tracking.php 2 months ago class-urls.php 6 months ago class-user-account-status.php 9 months ago class-users-connection-admin.php 2 months ago class-utils.php 2 years ago class-webhooks.php 1 month ago class-xmlrpc-async-call.php 2 years ago class-xmlrpc-connector.php 9 months ago interface-manager.php 4 years ago interface-storage-provider.php 6 months ago
class-error-handler.php
1232 lines
1 <?php
2 /**
3 * The Jetpack Connection error class file.
4 *
5 * @package automattic/jetpack-connection
6 */
7
8 namespace 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 */
61 class 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 // Incoming request token problems (Manager::internal_verify_xml_rpc_signature).
106 'malformed_token', // Token in the request is empty/garbled, or its API version doesn't match ours.
107 'malformed_user_id', // The user_id segment of the request token is not numeric.
108 'unknown_user', // The request token's user does not exist on this site.
109 // Locally stored token problems (Tokens::get_access_token).
110 'no_user_tokens', // The user_tokens option is empty; no user tokens exist at all.
111 'empty_master_user_option', // The owner's token was requested but the master_user option is empty.
112 'no_token_for_user', // No stored token for the requested user.
113 'token_malformed', // The stored token for the requested user is corrupt (missing chunks).
114 'user_id_mismatch', // The requested user ID doesn't match the user_id segment of their stored token.
115 'no_possible_tokens', // No stored blog token.
116 'no_valid_user_token', // The stored user token doesn't match the key the request was signed with.
117 'no_valid_blog_token', // The stored blog token doesn't match the key the request was signed with.
118 'unknown_token', // No stored token matches the request token's key.
119 // Signature verification problems (Jetpack_Signature), or errors WPCOM returned
120 // for an outbound request (Error_Handler::check_api_response_for_errors).
121 'could_not_sign', // Signing the request failed for an unknown reason.
122 'invalid_scheme', // Invalid URL scheme when signing.
123 'invalid_secret', // The stored token secret is invalid.
124 'invalid_token', // No token available when signing; from WPCOM: the token used was rejected.
125 'token_mismatch', // The request token doesn't match the token we hold.
126 'invalid_body', // The request body is malformed.
127 'invalid_signature', // A signature parameter is malformed, or the timestamp is off (clock skew).
128 'invalid_body_hash', // The body hash doesn't match the request body.
129 'invalid_nonce', // The request nonce could not be added (likely a reuse/replay).
130 'signature_mismatch', // Computed signature differs: wrong secret, or URL/body drift (domain change, proxy).
131 // Connection state problems (Manager::get_connection_owner).
132 'invalid_connection_owner', // The connection owner cannot be resolved: token missing or WP user deleted.
133 );
134
135 /**
136 * Holds the instance of this singleton class
137 *
138 * @since 1.14.2
139 *
140 * @var Error_Handler $instance
141 */
142 public static $instance = null;
143
144 /**
145 * Cached displayable errors to avoid duplicate processing
146 *
147 * @since 6.13.10
148 *
149 * @var array|null
150 */
151 private $cached_displayable_errors = null;
152
153 /**
154 * Initialize instance, hooks and load verified errors handlers
155 *
156 * @since 1.14.2
157 */
158 private function __construct() {
159 defined( 'JETPACK__ERRORS_PUBLIC_KEY' ) || define( 'JETPACK__ERRORS_PUBLIC_KEY', 'KdZY80axKX+nWzfrOcizf0jqiFHnrWCl9X8yuaClKgM=' );
160
161 add_action( 'rest_api_init', array( $this, 'register_verify_error_endpoint' ) );
162
163 // Handle verified errors on admin pages.
164 add_action( 'admin_init', array( $this, 'handle_verified_errors' ) );
165
166 // If the site gets reconnected, clear errors.
167 add_action( 'jetpack_site_registered', array( $this, 'delete_all_errors' ) );
168 add_action( 'jetpack_get_site_data_success', array( $this, 'delete_all_api_errors' ) );
169 add_filter( 'jetpack_connection_disconnect_site_wpcom', array( $this, 'delete_all_errors_and_return_unfiltered_value' ) );
170 add_filter( 'jetpack_connection_delete_all_tokens', array( $this, 'delete_all_errors_and_return_unfiltered_value' ) );
171 add_action( 'jetpack_unlinked_user', array( $this, 'delete_all_errors' ) );
172 add_action( 'jetpack_updated_user_token', array( $this, 'delete_all_errors' ) );
173 }
174
175 /**
176 * Gets displayable errors with predefined structure and optional filtering.
177 *
178 * This method returns a hierarchical array of errors (error_code => user_id => error_details)
179 * that can be safely displayed in My Jetpack and other UI components. It includes
180 * predefined error messages and actions, with optional filtering for specific sites.
181 * Only processes a limited set of error codes that are meant to be displayed to users.
182 *
183 * error_data.action is only set when it deviates from the default behavior
184 * (e.g. 'none' to suppress the reconnect CTA); when absent, readers fall back
185 * to offering the reconnect CTA.
186 *
187 * @since 6.13.10
188 *
189 * @return array Array of displayable errors with hierarchical structure.
190 * Example:
191 * [
192 * 'invalid_token' => [
193 * '123' => [
194 * 'error_code' => 'invalid_token',
195 * 'user_id' => '123',
196 * 'error_message' => 'Your connection with WordPress.com seems to be broken...',
197 * 'audience' => 'user',
198 * 'error_data' => [...],
199 * 'timestamp' => 1234567890,
200 * 'nonce' => 'abc123def',
201 * 'error_type' => 'xmlrpc'
202 * ]
203 * ]
204 * ]
205 */
206 public function get_displayable_errors() {
207 $viewer_id = get_current_user_id();
208
209 // Check if we have a cached result for this viewer AND no filters are applied.
210 // The output is viewer-dependent (see audience classification below), so the
211 // cache is keyed by the current user.
212 if ( is_array( $this->cached_displayable_errors )
213 && array_key_exists( $viewer_id, $this->cached_displayable_errors )
214 && ! $this->has_external_filters() ) {
215 return $this->cached_displayable_errors[ $viewer_id ];
216 }
217
218 $verified_errors = $this->get_verified_errors();
219 $displayable_errors = array();
220
221 // The common case is zero verified errors: skip the owner/transferability
222 // lookups entirely then. The external filter below still runs so consumers
223 // (e.g. wpcomsh) can inject errors into an empty set.
224 if ( ! empty( $verified_errors ) ) {
225 // Only process error codes that are meant to be displayed to users.
226 // `no_user_tokens` is deliberately excluded: with an empty user_tokens option the
227 // site already behaves as site-only connected, and the connection UI prompts users
228 // to connect their accounts. The owner flavor is covered by `invalid_connection_owner`.
229 $displayable_error_codes = array(
230 'malformed_token',
231 'token_malformed',
232 'no_possible_tokens',
233 'no_valid_user_token',
234 'no_valid_blog_token',
235 'unknown_token',
236 'could_not_sign',
237 'invalid_token',
238 'token_mismatch',
239 'invalid_signature',
240 'signature_mismatch',
241 'no_token_for_user',
242 'invalid_connection_owner',
243 );
244
245 $owner_id = (int) \Jetpack_Options::get_option( 'master_user' );
246 $viewer_is_owner = $owner_id > 0 && $viewer_id === $owner_id;
247 $is_transferable = ( new Manager() )->is_ownership_transferable();
248
249 foreach ( $verified_errors as $error_code => $users ) {
250 // Skip error codes that are not meant to be displayed
251 if ( ! in_array( $error_code, $displayable_error_codes, true ) ) {
252 continue;
253 }
254
255 foreach ( $users as $user_id => $error ) {
256 // An error that cannot be attributed to the blog token or to any user's
257 // token belongs to no audience and is not actionable by any viewer.
258 if ( 'invalid' === $user_id ) {
259 continue;
260 }
261
262 $audience = $this->classify_error_audience( $user_id, $owner_id );
263
264 $message = __( "Your connection with WordPress.com seems to be broken. If you're experiencing issues, please try reconnecting.", 'jetpack-connection' );
265 $action = null;
266
267 // A secondary admin looking at the connection owner's token error, on a
268 // site where ownership is locked (a consumer declared it non-transferable).
269 // This admin cannot resolve the error themselves, so surface an
270 // informational notice naming the owner and offer no reconnect CTA.
271 if ( 'owner' === $audience && ! $viewer_is_owner && ! $is_transferable ) {
272 // Only name the owner for viewers who can act on connection issues:
273 // this output is also printed into the initial state for
274 // lower-capability users (e.g. contributors in the editor), who
275 // shouldn't learn who owns the connection. The name is resolved from
276 // the local user rather than get_connection_owner(), which
277 // re-reports the error and fails exactly when the token is broken.
278 $owner_name = '';
279 if ( current_user_can( 'jetpack_connect' ) ) {
280 $owner = get_userdata( $owner_id );
281 $owner_name = $owner instanceof \WP_User ? $owner->display_name : '';
282 }
283
284 $message = $owner_name
285 ? sprintf(
286 /* translators: %s is the display name of the Jetpack connection owner. */
287 __( 'The connection owner (%s) needs to reconnect their WordPress.com account to restore the connection.', 'jetpack-connection' ),
288 $owner_name
289 )
290 : __( 'The connection owner needs to reconnect their WordPress.com account to restore the connection.', 'jetpack-connection' );
291 $action = 'none';
292 }
293
294 $error['audience'] = $audience;
295 $error['error_message'] = $message;
296
297 // Only emit error_data.action when it deviates from the default. Readers
298 // already fall back to the reconnect CTA when no action is set, and
299 // injecting an explicit 'reconnect' could trip consumer code paths
300 // reserved for custom actions.
301 if ( null !== $action ) {
302 $error_data = ( isset( $error['error_data'] ) && is_array( $error['error_data'] ) ) ? $error['error_data'] : array();
303 $error_data['action'] = $action;
304 $error['error_data'] = $error_data;
305 }
306
307 if ( ! isset( $displayable_errors[ $error_code ] ) ) {
308 $displayable_errors[ $error_code ] = array();
309 }
310 $displayable_errors[ $error_code ][ $user_id ] = $error;
311 }
312 }
313 }
314
315 /**
316 * Filter displayable connection errors to allow customization of error messages and actions.
317 *
318 * This filter allows sites to customize how connection errors are displayed,
319 * including modifying error messages, actions, and data. Access to this filter
320 * is controlled by should_allow_error_filtering().
321 *
322 * Consumer-injected errors take precedence over the default state. They are not
323 * required to carry the newer `audience` field: it is optional metadata used
324 * only for our own audience-aware messaging, and any reader must treat a missing
325 * value as site-wide (`$error['audience'] ?? 'site'`).
326 *
327 * @since 6.12.0
328 *
329 * @param array $displayable_errors Array of displayable errors with hierarchical structure.
330 * @param array $verified_errors Array of raw verified errors from the database.
331 */
332 if ( $this->should_allow_error_filtering() ) {
333 $displayable_errors = apply_filters( 'jetpack_connection_get_verified_errors', $displayable_errors, $verified_errors );
334 }
335
336 // Only cache if no external filters are applied
337 if ( ! $this->has_external_filters() ) {
338 if ( ! is_array( $this->cached_displayable_errors ) ) {
339 $this->cached_displayable_errors = array();
340 }
341 $this->cached_displayable_errors[ $viewer_id ] = $displayable_errors;
342 }
343
344 return $displayable_errors;
345 }
346
347 /**
348 * Classifies the audience of a stored connection error based on its user ID.
349 *
350 * The audience determines who a connection error is relevant to and, in turn,
351 * how it should be surfaced:
352 * - `site` : blog-token / site-wide errors (user ID `0`).
353 * - `owner` : errors tied to the connection owner's user token.
354 * - `user` : errors tied to a specific (non-owner) user's token.
355 *
356 * Unattributable errors (user ID 'invalid') are skipped by the display pipeline
357 * before classification, so this method only receives numeric user IDs.
358 *
359 * @since 8.8.0
360 *
361 * @param string|int $user_id The user ID associated with the error (`0` or a positive integer).
362 * @param int $owner_id The local user ID of the connection owner, or 0 if there is none.
363 * @return string One of 'site', 'owner', or 'user'.
364 */
365 private function classify_error_audience( $user_id, $owner_id ) {
366 $user_id = (int) $user_id;
367
368 if ( 0 === $user_id ) {
369 return 'site';
370 }
371
372 if ( $owner_id > 0 && $user_id === $owner_id ) {
373 return 'owner';
374 }
375
376 return 'user';
377 }
378
379 /**
380 * Sets up hooks for displaying verified errors on admin pages.
381 *
382 * This method is hooked into 'admin_init'. It retrieves displayable errors
383 * and, if any exist, sets up the necessary action and filter hooks to display
384 * them in admin notices and the React dashboard.
385 *
386 * @since 1.14.2
387 */
388 public function handle_verified_errors() {
389 $displayable_errors = $this->get_displayable_errors();
390
391 // If there are any displayable errors, set up the hooks for displaying them in React dashboard and admin notices.
392 if ( ! empty( $displayable_errors ) ) {
393 add_action( 'admin_notices', array( $this, 'generic_admin_notice_error' ) );
394 add_filter( 'react_connection_errors_initial_state', array( $this, 'jetpack_react_dashboard_error' ), 10, 1 );
395 }
396 }
397
398 /**
399 * Determines whether error filtering should be allowed.
400 *
401 * This method controls access to the jetpack_connection_displayable_errors filter.
402 * Currently, only WoA sites are allowed to use this filter.
403 *
404 * @since 6.13.10
405 *
406 * @return bool True if error filtering should be allowed, false otherwise.
407 */
408 protected function should_allow_error_filtering() {
409 $host = new \Automattic\Jetpack\Status\Host();
410 if ( $host->is_woa_site() || $host->is_vip_site() || $host->is_newspack_site() ) {
411 return true;
412 }
413
414 return false;
415 }
416
417 /**
418 * Provides displayable connection errors for the React dashboard in a flat array format.
419 *
420 * This method transforms the hierarchical displayable_errors structure into the flat format
421 * expected by the React dashboard. It's used as a filter for 'react_connection_errors_initial_state'.
422 * Returns only the first error to avoid overwhelming the user with multiple error messages.
423 *
424 * @since 8.9.0
425 *
426 * @param array $errors Existing errors from other filters (unused but required for filter signature).
427 * @return array Array containing only the first displayable error for the React dashboard.
428 * Example:
429 * [
430 * [
431 * 'code' => 'connection_error',
432 * 'message' => 'Your connection with WordPress.com seems to be broken...',
433 * 'action' => 'reconnect',
434 * 'data' => [
435 * 'api_error_code' => 'invalid_token',
436 * 'action' => 'reconnect',
437 * 'audience' => 'site' // Who the error is relevant to: 'site', 'owner', or 'user'.
438 * ]
439 * ]
440 * ]
441 */
442 public function jetpack_react_dashboard_error( $errors ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
443 $displayable_errors = $this->get_displayable_errors();
444
445 // Get the first error only
446 $first_error_code = array_key_first( $displayable_errors );
447 if ( ! $first_error_code ) {
448 return array(); // No errors
449 }
450
451 $first_user_errors = $displayable_errors[ $first_error_code ];
452 if ( ! is_array( $first_user_errors ) || empty( $first_user_errors ) ) {
453 return array(); // Invalid error structure
454 }
455
456 $first_error = reset( $first_user_errors );
457
458 // Validate error structure
459 if ( ! is_array( $first_error ) || ! isset( $first_error['error_message'] ) ) {
460 return array(); // Invalid error structure
461 }
462
463 // Determine the action - use the one from error_data if available, otherwise default to 'reconnect'
464 $action = 'reconnect'; // Default action for connection errors
465 if ( isset( $first_error['error_data']['action'] ) && is_string( $first_error['error_data']['action'] ) ) {
466 $action = $first_error['error_data']['action'];
467 }
468
469 // Safely merge error data, ensuring we don't overwrite critical fields
470 $error_data = isset( $first_error['error_data'] ) && is_array( $first_error['error_data'] ) ? $first_error['error_data'] : array();
471
472 // Build the data array with safe merging
473 $dashboard_data = array( 'api_error_code' => $first_error_code );
474
475 // Add error_data fields, but be careful not to overwrite api_error_code
476 foreach ( $error_data as $key => $value ) {
477 if ( 'api_error_code' !== $key ) {
478 $dashboard_data[ $key ] = $value;
479 }
480 }
481
482 // Expose the error audience (site/owner/user) so the dashboard can render
483 // audience-aware copy. Falls back to site-wide for consumer-injected errors
484 // that predate the audience field.
485 $dashboard_data['audience'] = $first_error['audience'] ?? 'site';
486
487 $dashboard_error = array(
488 array(
489 'code' => 'connection_error',
490 'message' => $first_error['error_message'],
491 'action' => $action,
492 'data' => $dashboard_data,
493 ),
494 );
495
496 return $dashboard_error;
497 }
498
499 /**
500 * Gets the instance of this singleton class
501 *
502 * @since 1.14.2
503 *
504 * @return Error_Handler $instance
505 */
506 public static function get_instance() {
507 if ( self::$instance === null ) {
508 self::$instance = new self();
509 }
510 return self::$instance;
511 }
512
513 /**
514 * Keep track of a connection error that was encountered
515 *
516 * @param \WP_Error $error The error object.
517 * @param boolean $force Force the report, even if should_report_error is false.
518 * @param boolean $skip_wpcom_verification Set to 'true' to verify the error locally and skip the WP.com verification.
519 *
520 * @return void
521 * @since 1.14.2
522 */
523 public function report_error( \WP_Error $error, $force = false, $skip_wpcom_verification = false ) {
524 if ( in_array( $error->get_error_code(), $this->known_errors, true ) && ( $this->should_report_error( $error ) || $force ) ) {
525 $stored_error = $this->store_error( $error );
526 if ( $stored_error ) {
527 $skip_wpcom_verification ? $this->verify_error( $stored_error ) : $this->send_error_to_wpcom( $stored_error );
528 }
529 }
530 }
531
532 /**
533 * Checks the status of the gate
534 *
535 * This protects the site (and WPCOM) against over loads.
536 *
537 * @since 1.14.2
538 *
539 * @param \WP_Error $error the error object.
540 * @return boolean $should_report True if gate is open and the error should be reported.
541 */
542 public function should_report_error( \WP_Error $error ) {
543 if ( defined( '\\JETPACK_DEV_DEBUG' ) && constant( '\\JETPACK_DEV_DEBUG' ) ) {
544 return true;
545 }
546
547 /**
548 * Whether to bypass the gate for the error handling
549 *
550 * By default, we only process errors once an hour for each error code.
551 * This is done to avoid overflows. If you need to disable this gate, you can set this variable to true.
552 *
553 * This filter is useful for unit testing
554 *
555 * @since 1.14.2
556 *
557 * @param boolean $bypass_gate whether to bypass the gate. Default is false, do not bypass.
558 */
559 $bypass_gate = apply_filters( 'jetpack_connection_bypass_error_reporting_gate', false );
560 if ( true === $bypass_gate ) {
561 return true;
562 }
563
564 $transient = self::ERROR_REPORTING_GATE . $error->get_error_code();
565
566 if ( get_transient( $transient ) ) {
567 return false;
568 }
569
570 set_transient( $transient, true, HOUR_IN_SECONDS );
571 return true;
572 }
573
574 /**
575 * Stores the error in the database so we know there is an issue and can inform the user
576 *
577 * @since 1.14.2
578 *
579 * @param \WP_Error $error the error object.
580 * @return boolean|array False if stored errors were not updated and the error array if it was successfully stored.
581 */
582 public function store_error( \WP_Error $error ) {
583
584 $stored_errors = $this->get_stored_errors();
585 $error_array = $this->wp_error_to_array( $error );
586 $error_code = $error->get_error_code();
587 $user_id = $error_array['user_id'];
588
589 if ( ! isset( $stored_errors[ $error_code ] ) || ! is_array( $stored_errors[ $error_code ] ) ) {
590 $stored_errors[ $error_code ] = array();
591 }
592
593 $stored_errors[ $error_code ][ $user_id ] = $error_array;
594
595 // Let's store a maximum of 5 different user ids for each error code.
596 $error_code_count = is_countable( $stored_errors[ $error_code ] ) ? count( $stored_errors[ $error_code ] ) : 0;
597 if ( $error_code_count > 5 ) {
598 // array_shift will destroy keys here because they are numeric, so manually remove first item.
599 $keys = array_keys( $stored_errors[ $error_code ] );
600 unset( $stored_errors[ $error_code ][ $keys[0] ] );
601 }
602
603 if ( update_option( self::STORED_ERRORS_OPTION, $stored_errors ) ) {
604 return $error_array;
605 }
606
607 return false;
608 }
609
610 /**
611 * Builds action error data for generic JavaScript components.
612 *
613 * This helper method creates standardized error_data arrays that work with the generic
614 * JavaScript error handling components. External plugins (like wpcomsh) can use this
615 * to ensure their error structures are compatible.
616 *
617 * @since 6.16.0
618 *
619 * @param array $args Action configuration arguments - only non-empty values will be included.
620 * @return array Standardized error_data array for JavaScript components.
621 */
622 public function build_action_error_data( array $args = array() ) {
623 // Set default values for variants
624 $args = wp_parse_args(
625 $args,
626 array(
627 'action_variant' => 'primary',
628 'secondary_action_variant' => 'secondary',
629 )
630 );
631
632 // Start with core data
633 $error_data = array(
634 'blog_id' => \Jetpack_Options::get_option( 'id' ),
635 );
636
637 // Validate variant values
638 $valid_variants = array( 'primary', 'secondary' );
639 if ( ! in_array( $args['action_variant'], $valid_variants, true ) ) {
640 $args['action_variant'] = 'primary';
641 }
642 if ( ! in_array( $args['secondary_action_variant'], $valid_variants, true ) ) {
643 $args['secondary_action_variant'] = 'secondary';
644 }
645
646 // Merge extra_data first, then regular args (so args take precedence)
647 if ( ! empty( $args['extra_data'] ) && is_array( $args['extra_data'] ) ) {
648 $error_data = array_merge( $error_data, $args['extra_data'] );
649 unset( $args['extra_data'] ); // Remove from args to avoid duplication
650 }
651
652 // Filter out empty values and merge with error_data
653 $filtered_args = array_filter(
654 $args,
655 function ( $value ) {
656 return ! empty( $value );
657 }
658 );
659
660 return array_merge( $error_data, $filtered_args );
661 }
662
663 /**
664 * Builds a standardized error array for the connection error system.
665 *
666 * This method creates a consistent error array structure that can be used
667 * by both internal error handling and external plugins/customizations.
668 *
669 * @since 1.14.2
670 *
671 * @param string $error_code The error code identifier.
672 * @param string $error_message The human-readable error message.
673 * @param array $error_data Additional error data (optional).
674 * @param string $user_id The user ID associated with the error (optional).
675 * @param string $error_type The type of error (optional).
676 * @return array|false The standardized error array or false on failure.
677 * Example successful return:
678 * [
679 * 'error_code' => 'invalid_token',
680 * 'user_id' => '123',
681 * 'error_message' => 'The token is invalid',
682 * 'error_data' => ['action' => 'reconnect'],
683 * 'timestamp' => 1234567890,
684 * 'nonce' => 'abc123def',
685 * 'error_type' => 'xmlrpc'
686 * ]
687 */
688 public function build_error_array( string $error_code, string $error_message, array $error_data = array(), $user_id = '0', string $error_type = '' ) {
689 // Validate required parameters
690 if ( empty( $error_code ) || empty( $error_message ) ) {
691 return false;
692 }
693
694 // Validate user_id is a string or integer
695 if ( ! is_string( $user_id ) && ! is_int( $user_id ) ) {
696 return false;
697 }
698
699 return array(
700 'error_code' => $error_code,
701 'user_id' => $user_id,
702 'error_message' => $error_message,
703 'error_data' => $error_data,
704 'timestamp' => time(),
705 'nonce' => wp_generate_password( 10, false ),
706 'error_type' => $error_type,
707 );
708 }
709
710 /**
711 * Converts a WP_Error object in the array representation we store in the database
712 *
713 * The user attribution comes from the token in `signature_details`, which identifies
714 * the exact credential that failed. An explicit `user_id` in the error data is only
715 * consulted as a fallback when the token yields no user (e.g. non-signature errors
716 * such as `invalid_connection_owner`, which are reported with an empty token).
717 *
718 * @since 1.14.2
719 *
720 * @param \WP_Error $error the error object.
721 * @return boolean|array False if error is invalid or the error array
722 */
723 public function wp_error_to_array( \WP_Error $error ) {
724
725 $data = $error->get_error_data();
726
727 if ( ! isset( $data['signature_details'] ) || ! is_array( $data['signature_details'] ) ) {
728 return false;
729 }
730
731 $signature_details = $data['signature_details'];
732
733 if ( ! isset( $signature_details['token'] ) ) {
734 return false;
735 }
736
737 $user_id = $this->get_user_id_from_token( $signature_details['token'] );
738
739 if ( 'invalid' === $user_id && isset( $data['user_id'] ) && is_numeric( $data['user_id'] ) ) {
740 $user_id = (string) (int) $data['user_id'];
741 }
742
743 $error_data = $signature_details;
744
745 // For invalid_connection_owner, has_user_token distinguishes a missing owner
746 // token from a deleted owner WP user. Keep it so display code can tell the
747 // two flavors apart.
748 if ( isset( $data['has_user_token'] ) ) {
749 $error_data['has_user_token'] = (bool) $data['has_user_token'];
750 }
751
752 return $this->build_error_array(
753 $error->get_error_code(),
754 $error->get_error_message(),
755 $error_data,
756 $user_id,
757 empty( $data['error_type'] ) ? '' : $data['error_type']
758 );
759 }
760
761 /**
762 * Sends the error to WP.com to be verified
763 *
764 * @since 1.14.2
765 *
766 * @param array $error_array The array representation of the error as it is stored in the database.
767 * @return bool
768 */
769 public function send_error_to_wpcom( $error_array ) {
770
771 $blog_id = \Jetpack_Options::get_option( 'id' );
772
773 $encrypted_data = $this->encrypt_data_to_wpcom( $error_array );
774
775 if ( false === $encrypted_data ) {
776 return false;
777 }
778
779 $args = array(
780 'body' => array(
781 'error_data' => $encrypted_data,
782 ),
783 );
784
785 // send encrypted data to WP.com Public-API v2.
786 wp_remote_post( "https://public-api.wordpress.com/wpcom/v2/sites/{$blog_id}/jetpack-report-error/", $args );
787 return true;
788 }
789
790 /**
791 * Encrypt data to be sent over to WP.com
792 *
793 * @since 1.14.2
794 *
795 * @param array|string $data the data to be encoded.
796 * @return boolean|string The encoded string on success, false on failure
797 */
798 public function encrypt_data_to_wpcom( $data ) {
799
800 try {
801 // phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
802 // phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
803 $encrypted_data = base64_encode( sodium_crypto_box_seal( wp_json_encode( $data, JSON_UNESCAPED_SLASHES ), base64_decode( JETPACK__ERRORS_PUBLIC_KEY ) ) );
804 // phpcs:enable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
805 // phpcs:enable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
806 } catch ( \SodiumException $e ) {
807 // error encrypting data.
808 return false;
809 }
810
811 return $encrypted_data;
812 }
813
814 /**
815 * Extracts the user ID from a token
816 *
817 * @since 1.14.2
818 *
819 * @param string $token the token used to make the request.
820 * @return string $the user id or `invalid` if user id not present.
821 */
822 public function get_user_id_from_token( $token ) {
823 $user_id = 'invalid';
824
825 if ( $token ) {
826 $parsed_token = explode( ':', wp_unslash( $token ) );
827
828 if ( isset( $parsed_token[2] ) && ctype_digit( $parsed_token[2] ) ) {
829 $user_id = $parsed_token[2];
830 }
831 }
832
833 return $user_id;
834 }
835
836 /**
837 * Gets the reported errors stored in the database
838 *
839 * @since 1.14.2
840 *
841 * @return array $errors
842 */
843 public function get_stored_errors() {
844
845 $stored_errors = get_option( self::STORED_ERRORS_OPTION );
846
847 if ( ! is_array( $stored_errors ) ) {
848 $stored_errors = array();
849 }
850
851 $stored_errors = $this->garbage_collector( $stored_errors );
852
853 return $stored_errors;
854 }
855
856 /**
857 * Gets the verified errors stored in the database.
858 *
859 * This method retrieves only the errors that are actually stored in the database,
860 * without applying any filters that might inject additional errors. This is used
861 * internally by methods that need to modify and store the verified errors back
862 * to the database to prevent accidentally persisting filtered/injected errors.
863 *
864 * @since 1.14.2
865 *
866 * @return array $errors
867 */
868 public function get_verified_errors() {
869 $verified_errors = get_option( self::STORED_VERIFIED_ERRORS_OPTION );
870
871 if ( ! is_array( $verified_errors ) ) {
872 $verified_errors = array();
873 }
874
875 $verified_errors = $this->garbage_collector( $verified_errors );
876
877 return $verified_errors;
878 }
879
880 /**
881 * Removes expired errors from the array
882 *
883 * This method is called by get_stored_errors and get_verified errors and filters their result
884 * Whenever a new error is stored to the database or verified, this will be triggered and the
885 * expired error will be permanently removed from the database
886 *
887 * @since 1.14.2
888 *
889 * @param array $errors array of errors as stored in the database.
890 * @return array
891 */
892 private function garbage_collector( $errors ) {
893 foreach ( $errors as $error_code => $users ) {
894 foreach ( $users as $user_id => $error ) {
895 if ( empty( $error['timestamp'] ) || self::ERROR_LIFE_TIME < time() - (int) $error['timestamp'] ) {
896 unset( $errors[ $error_code ][ $user_id ] );
897 }
898 }
899 }
900 // Clear empty error codes.
901 $errors = array_filter(
902 $errors,
903 function ( $user_errors ) {
904 return ! empty( $user_errors );
905 }
906 );
907 return $errors;
908 }
909
910 /**
911 * Delete all stored and verified errors from the database
912 *
913 * @since 1.14.2
914 *
915 * @return void
916 */
917 public function delete_all_errors() {
918 $this->delete_stored_errors();
919 $this->delete_verified_errors();
920
921 // Invalidate cache since we deleted all errors
922 $this->invalidate_displayable_errors_cache();
923 }
924
925 /**
926 * Delete all stored and verified API errors from the database, leave the non-API errors intact.
927 *
928 * @since 1.54.0
929 *
930 * @return void
931 */
932 public function delete_all_api_errors() {
933 $type_filter = function ( $errors ) {
934 if ( is_array( $errors ) ) {
935 foreach ( $errors as $key => $error ) {
936 if ( ! empty( $error['error_type'] ) && in_array( $error['error_type'], array( 'xmlrpc', 'rest' ), true ) ) {
937 unset( $errors[ $key ] );
938 }
939 }
940 }
941
942 return count( $errors ) ? $errors : null;
943 };
944
945 $stored_errors = $this->get_stored_errors();
946 if ( is_array( $stored_errors ) && count( $stored_errors ) ) {
947 $stored_errors = array_filter( array_map( $type_filter, $stored_errors ) );
948 if ( count( $stored_errors ) ) {
949 update_option( static::STORED_ERRORS_OPTION, $stored_errors );
950 } else {
951 delete_option( static::STORED_ERRORS_OPTION );
952 }
953 }
954
955 $verified_errors = $this->get_verified_errors();
956 if ( is_array( $verified_errors ) && count( $verified_errors ) ) {
957 $verified_errors = array_filter( array_map( $type_filter, $verified_errors ) );
958 if ( count( $verified_errors ) ) {
959 update_option( static::STORED_VERIFIED_ERRORS_OPTION, $verified_errors );
960 } else {
961 delete_option( static::STORED_VERIFIED_ERRORS_OPTION );
962 }
963 }
964
965 // Invalidate cache since we may have deleted verified errors
966 $this->invalidate_displayable_errors_cache();
967 }
968
969 /**
970 * Delete all stored and verified errors from the database and returns unfiltered value
971 *
972 * This is used to hook into a couple of filters that expect true to not short circuit the disconnection flow
973 *
974 * @since 8.9.0
975 *
976 * @param mixed $check The input sent by the filter.
977 * @return boolean
978 */
979 public function delete_all_errors_and_return_unfiltered_value( $check ) {
980 $this->delete_all_errors();
981 return $check;
982 }
983
984 /**
985 * Delete the reported errors stored in the database
986 *
987 * @since 1.14.2
988 *
989 * @return boolean True, if option is successfully deleted. False on failure.
990 */
991 public function delete_stored_errors() {
992 return delete_option( self::STORED_ERRORS_OPTION );
993 }
994
995 /**
996 * Delete the verified errors stored in the database
997 *
998 * @since 1.14.2
999 *
1000 * @return boolean True, if option is successfully deleted. False on failure.
1001 */
1002 public function delete_verified_errors() {
1003 return delete_option( self::STORED_VERIFIED_ERRORS_OPTION );
1004 }
1005
1006 /**
1007 * Gets an error based on the nonce
1008 *
1009 * Receives a nonce and finds the related error.
1010 *
1011 * @since 1.14.2
1012 *
1013 * @param string $nonce The nonce created for the error we want to get.
1014 * @return null|array Returns the error array representation or null if error not found.
1015 */
1016 public function get_error_by_nonce( $nonce ) {
1017 $errors = $this->get_stored_errors();
1018 foreach ( $errors as $user_group ) {
1019 foreach ( $user_group as $error ) {
1020 if ( $error['nonce'] === $nonce ) {
1021 return $error;
1022 }
1023 }
1024 }
1025 return null;
1026 }
1027
1028 /**
1029 * Adds an error to the verified error list
1030 *
1031 * @since 1.14.2
1032 *
1033 * @param array $error The error array, as it was saved in the unverified errors list.
1034 * @return void
1035 */
1036 public function verify_error( $error ) {
1037
1038 $verified_errors = $this->get_verified_errors();
1039 $error_code = $error['error_code'];
1040 $user_id = $error['user_id'];
1041
1042 if ( ! isset( $verified_errors[ $error_code ] ) ) {
1043 $verified_errors[ $error_code ] = array();
1044 }
1045
1046 $verified_errors[ $error_code ][ $user_id ] = $error;
1047
1048 update_option( self::STORED_VERIFIED_ERRORS_OPTION, $verified_errors );
1049
1050 // Invalidate cache since we added a new verified error
1051 $this->invalidate_displayable_errors_cache();
1052 }
1053
1054 /**
1055 * Register REST API end point for error handling.
1056 *
1057 * @since 1.14.2
1058 *
1059 * @return void
1060 */
1061 public function register_verify_error_endpoint() {
1062 register_rest_route(
1063 'jetpack/v4',
1064 '/verify_xmlrpc_error',
1065 array(
1066 'methods' => \WP_REST_Server::CREATABLE,
1067 'callback' => array( $this, 'verify_xml_rpc_error' ),
1068 'permission_callback' => '__return_true',
1069 'args' => array(
1070 'nonce' => array(
1071 'required' => true,
1072 'type' => 'string',
1073 ),
1074 ),
1075 )
1076 );
1077 }
1078
1079 /**
1080 * Handles verification that a xml rpc error is legit and came from WordPres.com
1081 *
1082 * @since 1.14.2
1083 *
1084 * @param \WP_REST_Request $request The request sent to the WP REST API.
1085 *
1086 * @return boolean
1087 */
1088 public function verify_xml_rpc_error( \WP_REST_Request $request ) {
1089 $error = $this->get_error_by_nonce( $request['nonce'] );
1090
1091 if ( $error ) {
1092 $this->verify_error( $error );
1093 return new \WP_REST_Response( true, 200 );
1094 }
1095
1096 return new \WP_REST_Response( false, 200 );
1097 }
1098
1099 /**
1100 * Prints a generic error notice for all connection errors
1101 *
1102 * @since 8.9.0
1103 *
1104 * @return void
1105 */
1106 public function generic_admin_notice_error() {
1107 // do not add admin notice to the jetpack dashboard.
1108 global $pagenow;
1109 if ( 'admin.php' === $pagenow || isset( $_GET['page'] ) && 'jetpack' === $_GET['page'] ) { // phpcs:ignore
1110 return;
1111 }
1112
1113 if ( ! current_user_can( 'jetpack_connect' ) ) {
1114 return;
1115 }
1116
1117 /**
1118 * Filters the message to be displayed in the admin notices area when there's a connection error.
1119 *
1120 * By default we don't display any errors.
1121 *
1122 * Return an empty value to disable the message.
1123 *
1124 * @since 8.9.0
1125 *
1126 * @param string $message The error message.
1127 * @param array $errors The array of errors. See Automattic\Jetpack\Connection\Error_Handler for details on the array structure.
1128 */
1129 $message = apply_filters( 'jetpack_connection_error_notice_message', '', $this->get_displayable_errors() );
1130
1131 /**
1132 * Fires inside the admin_notices hook just before displaying the error message for a broken connection.
1133 *
1134 * If you want to disable the default message from being displayed, return an empty value in the jetpack_connection_error_notice_message filter.
1135 *
1136 * @since 8.9.0
1137 *
1138 * @param array $errors The array of errors. See Automattic\Jetpack\Connection\Error_Handler for details on the array structure.
1139 */
1140 do_action( 'jetpack_connection_error_notice', $this->get_displayable_errors() );
1141
1142 if ( empty( $message ) ) {
1143 return;
1144 }
1145
1146 wp_admin_notice(
1147 esc_html( $message ),
1148 array(
1149 'type' => 'error',
1150 'dismissible' => true,
1151 'additional_classes' => array( 'jetpack-message', 'jp-connect' ),
1152 'attributes' => array( 'style' => 'display:block !important;' ),
1153 )
1154 );
1155 }
1156
1157 /**
1158 * Check REST API response for errors, and report them to WP.com if needed.
1159 *
1160 * @see wp_remote_request() For more information on the $http_response array format.
1161 * @param array|\WP_Error $http_response The response or WP_Error on failure.
1162 * @param array $auth_data Auth data, allowed keys: `token`, `timestamp`, `nonce`, `body-hash`.
1163 * @param string $url Request URL.
1164 * @param string $method Request method.
1165 * @param string $error_type The source of an error: 'xmlrpc' or 'rest'.
1166 *
1167 * @return void
1168 */
1169 public function check_api_response_for_errors( $http_response, $auth_data, $url, $method, $error_type ) {
1170 if ( 200 === wp_remote_retrieve_response_code( $http_response ) || ! is_array( $auth_data ) || ! $url || ! $method ) {
1171 return;
1172 }
1173
1174 $body_raw = wp_remote_retrieve_body( $http_response );
1175 if ( ! $body_raw ) {
1176 return;
1177 }
1178
1179 $body = json_decode( $body_raw, true );
1180
1181 // Support both error envelopes: the legacy v1 JSON-API shape (`error`) and the
1182 // WP-API v2 shape (`code`), the latter used by `wpcom/v2` endpoints such as
1183 // `jetpack-wpcom-user-data`. Prefer `error` for backwards compatibility.
1184 $error_code = is_array( $body ) ? ( $body['error'] ?? $body['code'] ?? null ) : null;
1185
1186 if ( empty( $error_code ) || ( ! is_string( $error_code ) && ! is_int( $error_code ) ) ) {
1187 return;
1188 }
1189
1190 $error = new \WP_Error(
1191 $error_code,
1192 empty( $body['message'] ) ? '' : $body['message'],
1193 array(
1194 'signature_details' => array(
1195 'token' => empty( $auth_data['token'] ) ? '' : $auth_data['token'],
1196 'timestamp' => empty( $auth_data['timestamp'] ) ? '' : $auth_data['timestamp'],
1197 'nonce' => empty( $auth_data['nonce'] ) ? '' : $auth_data['nonce'],
1198 'body_hash' => empty( $auth_data['body_hash'] ) ? '' : $auth_data['body_hash'],
1199 'method' => $method,
1200 'url' => $url,
1201 ),
1202 'error_type' => in_array( $error_type, array( 'xmlrpc', 'rest' ), true ) ? $error_type : '',
1203 )
1204 );
1205
1206 $this->report_error( $error, false, true );
1207 }
1208
1209 /**
1210 * Determines whether external filters are applied to the get_displayable_errors method.
1211 *
1212 * @since 6.13.10
1213 *
1214 * @return bool True if external filters are applied, false otherwise.
1215 */
1216 private function has_external_filters() {
1217 return has_filter( 'jetpack_connection_get_verified_errors' ) &&
1218 $this->should_allow_error_filtering();
1219 }
1220
1221 /**
1222 * Invalidates the cached displayable errors
1223 *
1224 * @since 6.13.10
1225 *
1226 * @return void
1227 */
1228 private function invalidate_displayable_errors_cache() {
1229 $this->cached_displayable_errors = null;
1230 }
1231 }
1232