PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.1
Jetpack – WP Security, Backup, Speed, & Growth v16.1
12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 13.9.2 14.0.1 14.1.1 14.2.2 14.3.1 14.4.2 14.5.1 14.6.1 14.7.1 14.8.1 14.9.2 15.0.3 15.1.2 15.2.1 15.3.2 15.4.1 15.5.1 15.6.1 15.7.2 15.8.1 15.9.2 16.0.2 16.1.3 16.2-a.5 16.2-a.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 3 months ago connectors 2 months ago health 2 months ago identity-crisis 2 months ago sso 2 months ago traits 9 months ago webhooks 9 months ago class-authorize-json-api.php 2 months ago class-client.php 3 weeks ago class-connection-assets.php 1 year ago class-connection-notice.php 8 months ago class-error-handler.php 3 weeks ago class-external-storage.php 4 months ago class-heartbeat.php 1 month ago class-initial-state.php 1 month ago class-manager.php 3 weeks ago class-nonce-handler.php 9 months ago class-package-version-tracker.php 2 months ago class-package-version.php 3 weeks ago class-partner-coupon.php 3 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 1 month ago class-secrets.php 9 months ago class-server-sandbox.php 3 months ago class-site-health.php 3 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 3 months ago class-urls.php 6 months ago class-user-account-status.php 9 months ago class-users-connection-admin.php 3 months ago class-utils.php 2 years ago class-webhooks.php 2 months 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
1423 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 error handler.
12 *
13 * This class stores and surfaces connection (authentication/signature) errors for requests
14 * in both directions: incoming (WP.com to this site) and outgoing (this site to WP.com).
15 *
16 * Flow 1 — incoming request errors. Entry point: `report_error()`.
17 *
18 * 1. An incoming XML-RPC or REST API request with an invalid signature triggers an error in
19 * `Manager::verify_xml_rpc_signature()`, which reports it here. (Signed incoming REST
20 * requests are funneled into the same verification path by `REST_Authentication`.)
21 * 2. Applies a gate to only process each error code once an hour to avoid overflow
22 * 3. It stores the error on the database, but we don't know yet if this is a valid error, because
23 * we can't confirm it came from WP.com.
24 * 4. It encrypts the error details and sends it to the wp.com server
25 * 5. wp.com checks it and, if valid, sends a new request back to this site using the verify_xml_rpc_error REST endpoint
26 * 6. This endpoint adds this error to the Verified errors in the database
27 * 7. Triggers a workflow depending on the error (display user an error message, do some self healing, etc.)
28 *
29 * Flow 2 — outgoing request errors. Entry point: `check_api_response_for_errors()`.
30 *
31 * 1. Every signed request made through `Client::remote_request()` has its response checked
32 * by `check_api_response_for_errors()`.
33 * 2. When the response carries a known error code, the error is stored and immediately
34 * marked verified (the same hourly gate applies). The WP.com verification round-trip of
35 * flow 1 is skipped because the error arrived in a response to a request this site
36 * itself initiated and signed — the failed response is its own evidence.
37 *
38 * Stored errors carry two orthogonal classification fields:
39 *
40 * - `error_type` — the transport/source of the failed request: 'xmlrpc', 'rest',
41 * 'local_state' (local connection-state errors involving no request at all, e.g.
42 * `invalid_connection_owner`; stored as 'connection' by package versions <= 8.8),
43 * or '' for entries stored by older package versions.
44 * - `error_direction` — 'incoming', 'outgoing', or '' (legacy entries and
45 * 'local_state'-type errors, which have no direction).
46 *
47 * Note on naming: both option names below contain "xmlrpc" because they predate REST
48 * support. They are intentionally kept as-is to avoid a data migration and breaking
49 * consumers that read the options directly — despite the names, they store errors of
50 * every type and direction.
51 *
52 * Errors are stored in the database as options in the following format:
53 *
54 * [
55 * $error_code => [
56 * $user_id => [
57 * $error_details
58 * ]
59 * ]
60 * ]
61 *
62 * For each error code we store a maximum of 5 errors for 5 different user ids.
63 *
64 * A user ID can be:
65 * * 0 for blog tokens
66 * * positive integer for user tokens
67 * * 'invalid' for malformed tokens
68 *
69 * Example error structure:
70 * [
71 * 'invalid_token' => [
72 * '123' => [
73 * 'error_code' => 'invalid_token',
74 * 'user_id' => '123',
75 * 'error_message' => 'The token is invalid',
76 * 'error_data' => ['action' => 'reconnect'],
77 * 'timestamp' => 1234567890,
78 * 'nonce' => 'abc123def',
79 * 'error_type' => 'xmlrpc',
80 * 'error_direction' => 'incoming'
81 * ]
82 * ]
83 * ]
84 *
85 * @since 1.14.2
86 */
87 class Error_Handler {
88
89 /**
90 * The name of the option that stores the errors
91 *
92 * @since 1.14.2
93 *
94 * @var string
95 */
96 const STORED_ERRORS_OPTION = 'jetpack_connection_xmlrpc_errors';
97
98 /**
99 * The name of the option that stores the errors
100 *
101 * @since 1.14.2
102 *
103 * @var string
104 */
105 const STORED_VERIFIED_ERRORS_OPTION = 'jetpack_connection_xmlrpc_verified_errors';
106
107 /**
108 * The prefix of the transient that controls the gate for each error code
109 *
110 * @since 1.14.2
111 *
112 * @var string
113 */
114 const ERROR_REPORTING_GATE = 'jetpack_connection_error_reporting_gate_';
115
116 /**
117 * `error_type` value for errors from XML-RPC requests.
118 *
119 * @since 8.9.0
120 *
121 * @var string
122 */
123 const ERROR_TYPE_XMLRPC = 'xmlrpc';
124
125 /**
126 * `error_type` value for errors from REST requests.
127 *
128 * @since 8.9.0
129 *
130 * @var string
131 */
132 const ERROR_TYPE_REST = 'rest';
133
134 /**
135 * `error_type` value for local connection-state errors that involve no request,
136 * e.g. `invalid_connection_owner`. The evidence for these errors is the site's own
137 * database, which is also why they carry no `error_direction`.
138 *
139 * Note: package versions <= 8.8 stored these errors with the type 'connection'.
140 *
141 * @since 8.9.0
142 *
143 * @var string
144 */
145 const ERROR_TYPE_LOCAL_STATE = 'local_state';
146
147 /**
148 * `error_direction` value for errors triggered by incoming requests (WP.com to this site).
149 *
150 * @since 8.9.0
151 *
152 * @var string
153 */
154 const DIRECTION_INCOMING = 'incoming';
155
156 /**
157 * `error_direction` value for errors triggered by outgoing requests (this site to WP.com).
158 *
159 * @since 8.9.0
160 *
161 * @var string
162 */
163 const DIRECTION_OUTGOING = 'outgoing';
164
165 /**
166 * Time in seconds a test should live in the database before being discarded
167 *
168 * @since 1.14.2
169 */
170 const ERROR_LIFE_TIME = DAY_IN_SECONDS;
171
172 /**
173 * List of known errors. Only error codes in this list will be handled
174 *
175 * @since 1.14.2
176 *
177 * @var array
178 */
179 public $known_errors = array(
180 // Incoming request token problems (Manager::internal_verify_xml_rpc_signature).
181 'malformed_token', // Token in the request is empty/garbled, or its API version doesn't match ours.
182 'malformed_user_id', // The user_id segment of the request token is not numeric.
183 'unknown_user', // The request token's user does not exist on this site.
184 // Locally stored token problems (Tokens::get_access_token).
185 'no_user_tokens', // The user_tokens option is empty; no user tokens exist at all.
186 'empty_master_user_option', // The owner's token was requested but the master_user option is empty.
187 'no_token_for_user', // No stored token for the requested user.
188 'token_malformed', // The stored token for the requested user is corrupt (missing chunks).
189 'user_id_mismatch', // The requested user ID doesn't match the user_id segment of their stored token.
190 'no_possible_tokens', // No stored blog token.
191 'no_valid_user_token', // The stored user token doesn't match the key the request was signed with.
192 'no_valid_blog_token', // The stored blog token doesn't match the key the request was signed with.
193 'unknown_token', // No stored token matches the request token's key.
194 // Signature verification problems (Jetpack_Signature), or errors WPCOM returned
195 // for an outbound request (Error_Handler::check_api_response_for_errors).
196 'could_not_sign', // Signing the request failed for an unknown reason.
197 'invalid_scheme', // Invalid URL scheme when signing.
198 'invalid_secret', // The stored token secret is invalid.
199 'invalid_token', // No token available when signing; from WPCOM: the token used was rejected.
200 'token_mismatch', // The request token doesn't match the token we hold.
201 'invalid_body', // The request body is malformed.
202 'invalid_signature', // A signature parameter is malformed, or the timestamp is off (clock skew).
203 'invalid_body_hash', // The body hash doesn't match the request body.
204 'invalid_nonce', // The request nonce could not be added (likely a reuse/replay).
205 'signature_mismatch', // Computed signature differs: wrong secret, or URL/body drift (domain change, proxy).
206 // Connection state problems (Manager::get_connection_owner).
207 'invalid_connection_owner', // The connection owner cannot be resolved: token missing or WP user deleted.
208 );
209
210 /**
211 * Holds the instance of this singleton class
212 *
213 * @since 1.14.2
214 *
215 * @var Error_Handler $instance
216 */
217 public static $instance = null;
218
219 /**
220 * Cached displayable errors to avoid duplicate processing
221 *
222 * @since 6.13.10
223 *
224 * @var array|null
225 */
226 private $cached_displayable_errors = null;
227
228 /**
229 * Initialize instance, hooks and load verified errors handlers
230 *
231 * @since 1.14.2
232 */
233 private function __construct() {
234 defined( 'JETPACK__ERRORS_PUBLIC_KEY' ) || define( 'JETPACK__ERRORS_PUBLIC_KEY', 'KdZY80axKX+nWzfrOcizf0jqiFHnrWCl9X8yuaClKgM=' );
235
236 add_action( 'rest_api_init', array( $this, 'register_verify_error_endpoint' ) );
237
238 // Handle verified errors on admin pages.
239 add_action( 'admin_init', array( $this, 'handle_verified_errors' ) );
240
241 // If the site gets reconnected, clear errors.
242 add_action( 'jetpack_site_registered', array( $this, 'delete_all_errors' ) );
243 add_action( 'jetpack_get_site_data_success', array( $this, 'delete_all_api_errors' ) );
244 add_filter( 'jetpack_connection_disconnect_site_wpcom', array( $this, 'delete_all_errors_and_return_unfiltered_value' ) );
245 add_filter( 'jetpack_connection_delete_all_tokens', array( $this, 'delete_all_errors_and_return_unfiltered_value' ) );
246 add_action( 'jetpack_unlinked_user', array( $this, 'delete_all_errors' ) );
247 add_action( 'jetpack_updated_user_token', array( $this, 'delete_all_errors' ) );
248 }
249
250 /**
251 * Gets displayable errors with predefined structure and optional filtering.
252 *
253 * This method returns a hierarchical array of errors (error_code => user_id => error_details)
254 * that can be safely displayed in My Jetpack and other UI components. It includes
255 * predefined error messages and actions, with optional filtering for specific sites.
256 * Only processes a limited set of error codes that are meant to be displayed to users.
257 *
258 * error_data.action is only set when it deviates from the default behavior
259 * (e.g. 'none' to suppress the reconnect CTA); when absent, readers fall back
260 * to offering the reconnect CTA.
261 *
262 * @since 6.13.10
263 *
264 * @return array Array of displayable errors with hierarchical structure.
265 * Example:
266 * [
267 * 'invalid_token' => [
268 * '123' => [
269 * 'error_code' => 'invalid_token',
270 * 'user_id' => '123',
271 * 'error_message' => 'Your connection with WordPress.com seems to be broken...',
272 * 'audience' => 'user',
273 * 'error_data' => [...],
274 * 'timestamp' => 1234567890,
275 * 'nonce' => 'abc123def',
276 * 'error_type' => 'xmlrpc'
277 * ]
278 * ]
279 * ]
280 */
281 public function get_displayable_errors() {
282 $viewer_id = get_current_user_id();
283
284 // Check if we have a cached result for this viewer AND no filters are applied.
285 // The output is viewer-dependent (see audience classification below), so the
286 // cache is keyed by the current user.
287 if ( is_array( $this->cached_displayable_errors )
288 && array_key_exists( $viewer_id, $this->cached_displayable_errors )
289 && ! $this->has_external_filters() ) {
290 return $this->cached_displayable_errors[ $viewer_id ];
291 }
292
293 $verified_errors = $this->get_verified_errors();
294 $displayable_errors = array();
295
296 // The common case is zero verified errors: skip the owner/transferability
297 // lookups entirely then. The external filter below still runs so consumers
298 // (e.g. wpcomsh) can inject errors into an empty set.
299 if ( ! empty( $verified_errors ) ) {
300 // Only process error codes that are meant to be displayed to users.
301 // `no_user_tokens` is deliberately excluded: with an empty user_tokens option the
302 // site already behaves as site-only connected, and the connection UI prompts users
303 // to connect their accounts. The owner flavor is covered by `invalid_connection_owner`.
304 $displayable_error_codes = array(
305 'malformed_token',
306 'token_malformed',
307 'no_possible_tokens',
308 'no_valid_user_token',
309 'no_valid_blog_token',
310 'unknown_token',
311 'could_not_sign',
312 'invalid_token',
313 'token_mismatch',
314 'invalid_signature',
315 'signature_mismatch',
316 'no_token_for_user',
317 'invalid_connection_owner',
318 );
319
320 $owner_id = (int) \Jetpack_Options::get_option( 'master_user' );
321 $viewer_is_owner = $owner_id > 0 && $viewer_id === $owner_id;
322 $is_transferable = ( new Manager() )->is_ownership_transferable();
323
324 foreach ( $verified_errors as $error_code => $users ) {
325 // Skip error codes that are not meant to be displayed
326 if ( ! in_array( $error_code, $displayable_error_codes, true ) ) {
327 continue;
328 }
329
330 foreach ( $users as $user_id => $error ) {
331 // An error that cannot be attributed to the blog token or to any user's
332 // token belongs to no audience and is not actionable by any viewer.
333 if ( 'invalid' === $user_id ) {
334 continue;
335 }
336
337 $audience = $this->classify_error_audience( $user_id, $owner_id );
338
339 $message = __( "Your connection with WordPress.com seems to be broken. If you're experiencing issues, please try reconnecting.", 'jetpack-connection' );
340 $action = null;
341
342 // A secondary admin looking at the connection owner's token error, on a
343 // site where ownership is locked (a consumer declared it non-transferable).
344 // This admin cannot resolve the error themselves, so surface an
345 // informational notice naming the owner and offer no reconnect CTA.
346 if ( 'owner' === $audience && ! $viewer_is_owner && ! $is_transferable ) {
347 // Only name the owner for viewers who can act on connection issues:
348 // this output is also printed into the initial state for
349 // lower-capability users (e.g. contributors in the editor), who
350 // shouldn't learn who owns the connection. The name is resolved from
351 // the local user rather than get_connection_owner(), which
352 // re-reports the error and fails exactly when the token is broken.
353 $owner_name = '';
354 if ( current_user_can( 'jetpack_connect' ) ) {
355 $owner = get_userdata( $owner_id );
356 $owner_name = $owner instanceof \WP_User ? $owner->display_name : '';
357 }
358
359 $message = $owner_name
360 ? sprintf(
361 /* translators: %s is the display name of the Jetpack connection owner. */
362 __( 'The connection owner (%s) needs to reconnect their WordPress.com account to restore the connection.', 'jetpack-connection' ),
363 $owner_name
364 )
365 : __( 'The connection owner needs to reconnect their WordPress.com account to restore the connection.', 'jetpack-connection' );
366 $action = 'none';
367 }
368
369 $error['audience'] = $audience;
370 $error['error_message'] = $message;
371
372 // Only emit error_data.action when it deviates from the default. Readers
373 // already fall back to the reconnect CTA when no action is set, and
374 // injecting an explicit 'reconnect' could trip consumer code paths
375 // reserved for custom actions.
376 if ( null !== $action ) {
377 $error_data = ( isset( $error['error_data'] ) && is_array( $error['error_data'] ) ) ? $error['error_data'] : array();
378 $error_data['action'] = $action;
379 $error['error_data'] = $error_data;
380 }
381
382 if ( ! isset( $displayable_errors[ $error_code ] ) ) {
383 $displayable_errors[ $error_code ] = array();
384 }
385 $displayable_errors[ $error_code ][ $user_id ] = $error;
386 }
387 }
388 }
389
390 /**
391 * Filter displayable connection errors to allow customization of error messages and actions.
392 *
393 * This filter allows sites to customize how connection errors are displayed,
394 * including modifying error messages, actions, and data. Access to this filter
395 * is controlled by should_allow_error_filtering().
396 *
397 * Consumer-injected errors take precedence over the default state. They are not
398 * required to carry the newer `audience` field: it is optional metadata used
399 * only for our own audience-aware messaging, and any reader must treat a missing
400 * value as site-wide (`$error['audience'] ?? 'site'`).
401 *
402 * @since 6.12.0
403 *
404 * @param array $displayable_errors Array of displayable errors with hierarchical structure.
405 * @param array $verified_errors Array of raw verified errors from the database.
406 */
407 if ( $this->should_allow_error_filtering() ) {
408 $displayable_errors = apply_filters( 'jetpack_connection_get_verified_errors', $displayable_errors, $verified_errors );
409 }
410
411 // Only cache if no external filters are applied
412 if ( ! $this->has_external_filters() ) {
413 if ( ! is_array( $this->cached_displayable_errors ) ) {
414 $this->cached_displayable_errors = array();
415 }
416 $this->cached_displayable_errors[ $viewer_id ] = $displayable_errors;
417 }
418
419 return $displayable_errors;
420 }
421
422 /**
423 * Classifies the audience of a stored connection error based on its user ID.
424 *
425 * The audience determines who a connection error is relevant to and, in turn,
426 * how it should be surfaced:
427 * - `site` : blog-token / site-wide errors (user ID `0`).
428 * - `owner` : errors tied to the connection owner's user token.
429 * - `user` : errors tied to a specific (non-owner) user's token.
430 *
431 * Unattributable errors (user ID 'invalid') are skipped by the display pipeline
432 * before classification, so this method only receives numeric user IDs.
433 *
434 * @since 8.8.0
435 *
436 * @param string|int $user_id The user ID associated with the error (`0` or a positive integer).
437 * @param int $owner_id The local user ID of the connection owner, or 0 if there is none.
438 * @return string One of 'site', 'owner', or 'user'.
439 */
440 private function classify_error_audience( $user_id, $owner_id ) {
441 $user_id = (int) $user_id;
442
443 if ( 0 === $user_id ) {
444 return 'site';
445 }
446
447 if ( $owner_id > 0 && $user_id === $owner_id ) {
448 return 'owner';
449 }
450
451 return 'user';
452 }
453
454 /**
455 * Sets up hooks for displaying verified errors on admin pages.
456 *
457 * This method is hooked into 'admin_init'. It retrieves displayable errors
458 * and, if any exist, sets up the necessary action and filter hooks to display
459 * them in admin notices and the React dashboard.
460 *
461 * @since 1.14.2
462 */
463 public function handle_verified_errors() {
464 $displayable_errors = $this->get_displayable_errors();
465
466 // If there are any displayable errors, set up the hooks for displaying them in React dashboard and admin notices.
467 if ( ! empty( $displayable_errors ) ) {
468 add_action( 'admin_notices', array( $this, 'generic_admin_notice_error' ) );
469 add_filter( 'react_connection_errors_initial_state', array( $this, 'jetpack_react_dashboard_error' ), 10, 1 );
470 }
471 }
472
473 /**
474 * Determines whether error filtering should be allowed.
475 *
476 * This method controls access to the jetpack_connection_displayable_errors filter.
477 * Currently, only WoA sites are allowed to use this filter.
478 *
479 * @since 6.13.10
480 *
481 * @return bool True if error filtering should be allowed, false otherwise.
482 */
483 protected function should_allow_error_filtering() {
484 $host = new \Automattic\Jetpack\Status\Host();
485 if ( $host->is_woa_site() || $host->is_vip_site() || $host->is_newspack_site() ) {
486 return true;
487 }
488
489 return false;
490 }
491
492 /**
493 * Provides displayable connection errors for the React dashboard in a flat array format.
494 *
495 * This method transforms the hierarchical displayable_errors structure into the flat format
496 * expected by the React dashboard. It's used as a filter for 'react_connection_errors_initial_state'.
497 * Returns only the first error to avoid overwhelming the user with multiple error messages.
498 *
499 * @since 8.9.0
500 *
501 * @param array $errors Existing errors from other filters (unused but required for filter signature).
502 * @return array Array containing only the first displayable error for the React dashboard.
503 * Example:
504 * [
505 * [
506 * 'code' => 'connection_error',
507 * 'message' => 'Your connection with WordPress.com seems to be broken...',
508 * 'action' => 'reconnect',
509 * 'data' => [
510 * 'api_error_code' => 'invalid_token',
511 * 'action' => 'reconnect',
512 * 'audience' => 'site' // Who the error is relevant to: 'site', 'owner', or 'user'.
513 * ]
514 * ]
515 * ]
516 */
517 public function jetpack_react_dashboard_error( $errors ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
518 $displayable_errors = $this->get_displayable_errors();
519
520 // Get the first error only
521 $first_error_code = array_key_first( $displayable_errors );
522 if ( ! $first_error_code ) {
523 return array(); // No errors
524 }
525
526 $first_user_errors = $displayable_errors[ $first_error_code ];
527 if ( ! is_array( $first_user_errors ) || empty( $first_user_errors ) ) {
528 return array(); // Invalid error structure
529 }
530
531 $first_error = reset( $first_user_errors );
532
533 // Validate error structure
534 if ( ! is_array( $first_error ) || ! isset( $first_error['error_message'] ) ) {
535 return array(); // Invalid error structure
536 }
537
538 // Determine the action - use the one from error_data if available, otherwise default to 'reconnect'
539 $action = 'reconnect'; // Default action for connection errors
540 if ( isset( $first_error['error_data']['action'] ) && is_string( $first_error['error_data']['action'] ) ) {
541 $action = $first_error['error_data']['action'];
542 }
543
544 // Safely merge error data, ensuring we don't overwrite critical fields
545 $error_data = isset( $first_error['error_data'] ) && is_array( $first_error['error_data'] ) ? $first_error['error_data'] : array();
546
547 // Build the data array with safe merging
548 $dashboard_data = array( 'api_error_code' => $first_error_code );
549
550 // Add error_data fields, but be careful not to overwrite api_error_code
551 foreach ( $error_data as $key => $value ) {
552 if ( 'api_error_code' !== $key ) {
553 $dashboard_data[ $key ] = $value;
554 }
555 }
556
557 // Expose the error audience (site/owner/user) so the dashboard can render
558 // audience-aware copy. Falls back to site-wide for consumer-injected errors
559 // that predate the audience field.
560 $dashboard_data['audience'] = $first_error['audience'] ?? 'site';
561
562 $dashboard_error = array(
563 array(
564 'code' => 'connection_error',
565 'message' => $first_error['error_message'],
566 'action' => $action,
567 'data' => $dashboard_data,
568 ),
569 );
570
571 return $dashboard_error;
572 }
573
574 /**
575 * Gets the instance of this singleton class
576 *
577 * @since 1.14.2
578 *
579 * @return Error_Handler $instance
580 */
581 public static function get_instance() {
582 if ( self::$instance === null ) {
583 self::$instance = new self();
584 }
585 return self::$instance;
586 }
587
588 /**
589 * Keep track of a connection error that was encountered
590 *
591 * This is the entry point of the incoming-request error flow (flow 1 in the class
592 * docblock) when called with `$skip_wpcom_verification = false` (the default).
593 *
594 * Only error codes present in `$known_errors` are handled; anything else is
595 * silently discarded. The `WP_Error` must carry the data shape produced by
596 * `build_connection_error_data()`, or it is discarded as well.
597 *
598 * @param \WP_Error $error The error object.
599 * @param boolean $force Force the report, even if should_report_error is false.
600 * @param boolean $skip_wpcom_verification Set to 'true' to verify the error locally and skip the WP.com
601 * verification round-trip. Only do this when the error is self-evidencing — e.g. it came
602 * from a response WP.com sent to a request this site initiated (the outgoing flow), or
603 * from local connection state. Skipping verification for an incoming request error would
604 * let any unauthenticated requester plant a verified error and trigger its workflows
605 * (admin notices, self-healing), so leave it 'false' for anything derived from an
606 * incoming request.
607 *
608 * @return void
609 * @since 1.14.2
610 */
611 public function report_error( \WP_Error $error, $force = false, $skip_wpcom_verification = false ) {
612 if ( in_array( $error->get_error_code(), $this->known_errors, true ) && ( $this->should_report_error( $error ) || $force ) ) {
613 $stored_error = $this->store_error( $error );
614 if ( $stored_error ) {
615 $skip_wpcom_verification ? $this->verify_error( $stored_error ) : $this->send_error_to_wpcom( $stored_error );
616 }
617 }
618 }
619
620 /**
621 * Checks the status of the gate
622 *
623 * This protects the site (and WPCOM) against over loads.
624 *
625 * @since 1.14.2
626 *
627 * @param \WP_Error $error the error object.
628 * @return boolean $should_report True if gate is open and the error should be reported.
629 */
630 public function should_report_error( \WP_Error $error ) {
631 if ( defined( '\\JETPACK_DEV_DEBUG' ) && constant( '\\JETPACK_DEV_DEBUG' ) ) {
632 return true;
633 }
634
635 /**
636 * Whether to bypass the gate for the error handling
637 *
638 * By default, we only process errors once an hour for each error code.
639 * This is done to avoid overflows. If you need to disable this gate, you can set this variable to true.
640 *
641 * This filter is useful for unit testing
642 *
643 * @since 1.14.2
644 *
645 * @param boolean $bypass_gate whether to bypass the gate. Default is false, do not bypass.
646 */
647 $bypass_gate = apply_filters( 'jetpack_connection_bypass_error_reporting_gate', false );
648 if ( true === $bypass_gate ) {
649 return true;
650 }
651
652 $transient = self::ERROR_REPORTING_GATE . $error->get_error_code();
653
654 if ( get_transient( $transient ) ) {
655 return false;
656 }
657
658 set_transient( $transient, true, HOUR_IN_SECONDS );
659 return true;
660 }
661
662 /**
663 * Stores the error in the database so we know there is an issue and can inform the user
664 *
665 * @since 1.14.2
666 *
667 * @param \WP_Error $error the error object.
668 * @return boolean|array False if stored errors were not updated and the error array if it was successfully stored.
669 */
670 public function store_error( \WP_Error $error ) {
671
672 $stored_errors = $this->get_stored_errors();
673 $error_array = $this->wp_error_to_array( $error );
674 $error_code = $error->get_error_code();
675 $user_id = $error_array['user_id'];
676
677 if ( ! isset( $stored_errors[ $error_code ] ) || ! is_array( $stored_errors[ $error_code ] ) ) {
678 $stored_errors[ $error_code ] = array();
679 }
680
681 $stored_errors[ $error_code ][ $user_id ] = $error_array;
682
683 // Let's store a maximum of 5 different user ids for each error code.
684 $error_code_count = is_countable( $stored_errors[ $error_code ] ) ? count( $stored_errors[ $error_code ] ) : 0;
685 if ( $error_code_count > 5 ) {
686 // array_shift will destroy keys here because they are numeric, so manually remove first item.
687 $keys = array_keys( $stored_errors[ $error_code ] );
688 unset( $stored_errors[ $error_code ][ $keys[0] ] );
689 }
690
691 if ( update_option( self::STORED_ERRORS_OPTION, $stored_errors ) ) {
692 return $error_array;
693 }
694
695 return false;
696 }
697
698 /**
699 * Builds action error data for generic JavaScript components.
700 *
701 * This helper method creates standardized error_data arrays that work with the generic
702 * JavaScript error handling components. External plugins (like wpcomsh) can use this
703 * to ensure their error structures are compatible.
704 *
705 * @since 6.16.0
706 *
707 * @param array $args Action configuration arguments - only non-empty values will be included.
708 * @return array Standardized error_data array for JavaScript components.
709 */
710 public function build_action_error_data( array $args = array() ) {
711 // Set default values for variants
712 $args = wp_parse_args(
713 $args,
714 array(
715 'action_variant' => 'primary',
716 'secondary_action_variant' => 'secondary',
717 )
718 );
719
720 // Start with core data
721 $error_data = array(
722 'blog_id' => \Jetpack_Options::get_option( 'id' ),
723 );
724
725 // Validate variant values
726 $valid_variants = array( 'primary', 'secondary' );
727 if ( ! in_array( $args['action_variant'], $valid_variants, true ) ) {
728 $args['action_variant'] = 'primary';
729 }
730 if ( ! in_array( $args['secondary_action_variant'], $valid_variants, true ) ) {
731 $args['secondary_action_variant'] = 'secondary';
732 }
733
734 // Merge extra_data first, then regular args (so args take precedence)
735 if ( ! empty( $args['extra_data'] ) && is_array( $args['extra_data'] ) ) {
736 $error_data = array_merge( $error_data, $args['extra_data'] );
737 unset( $args['extra_data'] ); // Remove from args to avoid duplication
738 }
739
740 // Filter out empty values and merge with error_data
741 $filtered_args = array_filter(
742 $args,
743 function ( $value ) {
744 return ! empty( $value );
745 }
746 );
747
748 return array_merge( $error_data, $filtered_args );
749 }
750
751 /**
752 * Builds a standardized error array for the connection error system.
753 *
754 * This method creates a consistent error array structure that can be used
755 * by both internal error handling and external plugins/customizations.
756 *
757 * @since 1.14.2
758 * @since 8.9.0 Added the `$error_direction` parameter and output field.
759 *
760 * @param string $error_code The error code identifier.
761 * @param string $error_message The human-readable error message.
762 * @param array $error_data Additional error data (optional).
763 * @param string $user_id The user ID associated with the error (optional).
764 * @param string $error_type The type of error (optional). One of the `ERROR_TYPE_*` constants or ''.
765 * @param string $error_direction The direction of the request that triggered the error (optional).
766 * One of the `DIRECTION_*` constants or ''.
767 * @return array|false The standardized error array or false on failure.
768 * Example successful return:
769 * [
770 * 'error_code' => 'invalid_token',
771 * 'user_id' => '123',
772 * 'error_message' => 'The token is invalid',
773 * 'error_data' => ['action' => 'reconnect'],
774 * 'timestamp' => 1234567890,
775 * 'nonce' => 'abc123def',
776 * 'error_type' => 'xmlrpc',
777 * 'error_direction' => 'incoming'
778 * ]
779 */
780 public function build_error_array( string $error_code, string $error_message, array $error_data = array(), $user_id = '0', string $error_type = '', string $error_direction = '' ) {
781 // Validate required parameters
782 if ( empty( $error_code ) || empty( $error_message ) ) {
783 return false;
784 }
785
786 // Validate user_id is a string or integer
787 if ( ! is_string( $user_id ) && ! is_int( $user_id ) ) {
788 return false;
789 }
790
791 return array(
792 'error_code' => $error_code,
793 'user_id' => $user_id,
794 'error_message' => $error_message,
795 'error_data' => $error_data,
796 'timestamp' => time(),
797 'nonce' => wp_generate_password( 10, false ),
798 'error_type' => $error_type,
799 'error_direction' => $error_direction,
800 );
801 }
802
803 /**
804 * Builds the standardized `WP_Error` data payload for a connection error.
805 *
806 * This is the single place the error-data contract consumed by `wp_error_to_array()`
807 * is defined. Use it (or `build_connection_wp_error()`) instead of assembling the
808 * data array by hand, so every reporter produces the same shape:
809 *
810 * - `signature_details` is guaranteed to contain a `token` key (empty string when
811 * the error is not tied to a specific token), which `wp_error_to_array()` requires.
812 * The token is also what WP.com checks when verifying incoming-flow errors, so its
813 * key must not be renamed.
814 * - `error_type` and `error_direction` are validated against the class constants and
815 * stored as '' when the given value is not recognized. For 'local_state' errors the
816 * direction is always forced to '' — they describe the site's own database, not a
817 * request, so a direction would be meaningless and is ignored if passed.
818 * - `$extra` cannot override the reserved keys: `signature_details`, `error_type`,
819 * and `error_direction` always win the merge.
820 *
821 * @since 8.9.0
822 *
823 * @param array $signature_details Details of the signed request that failed: `token`,
824 * and typically `timestamp`, `nonce`, `body_hash`,
825 * `method`, `url`.
826 * @param string $error_type One of the `ERROR_TYPE_*` constants.
827 * @param string $error_direction One of the `DIRECTION_*` constants. Ignored for
828 * 'local_state' errors, which have no direction.
829 * @param array $extra Optional additional data, e.g. a `user_id` fallback for
830 * errors whose token cannot be attributed to a user, or
831 * `has_user_token` for `invalid_connection_owner`.
832 * @return array The error data array to pass as the third argument of `WP_Error`.
833 */
834 public static function build_connection_error_data( array $signature_details, string $error_type, string $error_direction, array $extra = array() ) {
835 $valid_types = array( self::ERROR_TYPE_XMLRPC, self::ERROR_TYPE_REST, self::ERROR_TYPE_LOCAL_STATE );
836 $valid_directions = array( self::DIRECTION_INCOMING, self::DIRECTION_OUTGOING );
837
838 $error_type = in_array( $error_type, $valid_types, true ) ? $error_type : '';
839
840 if ( self::ERROR_TYPE_LOCAL_STATE === $error_type ) {
841 $error_direction = '';
842 } else {
843 $error_direction = in_array( $error_direction, $valid_directions, true ) ? $error_direction : '';
844 }
845
846 return array_merge(
847 $extra,
848 array(
849 'signature_details' => array_merge( array( 'token' => '' ), $signature_details ),
850 'error_type' => $error_type,
851 'error_direction' => $error_direction,
852 )
853 );
854 }
855
856 /**
857 * Builds a `WP_Error` carrying the standardized connection error data.
858 *
859 * Convenience wrapper around `build_connection_error_data()` — see it for the
860 * data contract. All connection error reporters should create their `WP_Error`
861 * objects through this factory.
862 *
863 * @since 8.9.0
864 *
865 * @param string $error_code The error code, ideally one of `$known_errors`.
866 * @param string $error_message The human-readable error message.
867 * @param array $signature_details Details of the signed request that failed. See `build_connection_error_data()`.
868 * @param string $error_type One of the `ERROR_TYPE_*` constants.
869 * @param string $error_direction One of the `DIRECTION_*` constants, or '' for errors with no direction.
870 * @param array $extra Optional additional data. See `build_connection_error_data()`.
871 * @return \WP_Error
872 */
873 public static function build_connection_wp_error( string $error_code, string $error_message, array $signature_details, string $error_type, string $error_direction, array $extra = array() ) {
874 return new \WP_Error(
875 $error_code,
876 $error_message,
877 self::build_connection_error_data( $signature_details, $error_type, $error_direction, $extra )
878 );
879 }
880
881 /**
882 * Converts a WP_Error object in the array representation we store in the database
883 *
884 * The `WP_Error` data must follow the contract defined by `build_connection_error_data()`:
885 * a `signature_details` array containing at least a `token` key is required, and this
886 * method returns false without storing anything when it is absent. `error_type` and
887 * `error_direction` are read from the data and stored as '' when missing.
888 *
889 * The user attribution comes from the token in `signature_details`, which identifies
890 * the exact credential that failed. An explicit `user_id` in the error data is only
891 * consulted as a fallback when the token yields no user (e.g. non-signature errors
892 * such as `invalid_connection_owner`, which are reported with an empty token).
893 *
894 * @since 1.14.2
895 *
896 * @param \WP_Error $error the error object.
897 * @return boolean|array False if error is invalid or the error array
898 */
899 public function wp_error_to_array( \WP_Error $error ) {
900
901 $data = $error->get_error_data();
902
903 if ( ! isset( $data['signature_details'] ) || ! is_array( $data['signature_details'] ) ) {
904 return false;
905 }
906
907 $signature_details = $data['signature_details'];
908
909 if ( ! isset( $signature_details['token'] ) ) {
910 return false;
911 }
912
913 $user_id = $this->get_user_id_from_token( $signature_details['token'] );
914
915 if ( 'invalid' === $user_id && isset( $data['user_id'] ) && is_numeric( $data['user_id'] ) ) {
916 $user_id = (string) (int) $data['user_id'];
917 }
918
919 $error_data = $signature_details;
920
921 // For invalid_connection_owner, has_user_token distinguishes a missing owner
922 // token from a deleted owner WP user. Keep it so display code can tell the
923 // two flavors apart.
924 if ( isset( $data['has_user_token'] ) ) {
925 $error_data['has_user_token'] = (bool) $data['has_user_token'];
926 }
927
928 return $this->build_error_array(
929 $error->get_error_code(),
930 $error->get_error_message(),
931 $error_data,
932 $user_id,
933 empty( $data['error_type'] ) ? '' : $data['error_type'],
934 empty( $data['error_direction'] ) ? '' : $data['error_direction']
935 );
936 }
937
938 /**
939 * Sends the error to WP.com to be verified
940 *
941 * @since 1.14.2
942 *
943 * @param array $error_array The array representation of the error as it is stored in the database.
944 * @return bool
945 */
946 public function send_error_to_wpcom( $error_array ) {
947
948 $blog_id = \Jetpack_Options::get_option( 'id' );
949
950 $encrypted_data = $this->encrypt_data_to_wpcom( $error_array );
951
952 if ( false === $encrypted_data ) {
953 return false;
954 }
955
956 $args = array(
957 'body' => array(
958 'error_data' => $encrypted_data,
959 ),
960 );
961
962 // send encrypted data to WP.com Public-API v2.
963 wp_remote_post( "https://public-api.wordpress.com/wpcom/v2/sites/{$blog_id}/jetpack-report-error/", $args );
964 return true;
965 }
966
967 /**
968 * Encrypt data to be sent over to WP.com
969 *
970 * @since 1.14.2
971 *
972 * @param array|string $data the data to be encoded.
973 * @return boolean|string The encoded string on success, false on failure
974 */
975 public function encrypt_data_to_wpcom( $data ) {
976
977 try {
978 // phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
979 // phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
980 $encrypted_data = base64_encode( sodium_crypto_box_seal( wp_json_encode( $data, JSON_UNESCAPED_SLASHES ), base64_decode( JETPACK__ERRORS_PUBLIC_KEY ) ) );
981 // phpcs:enable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
982 // phpcs:enable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
983 } catch ( \SodiumException $e ) {
984 // error encrypting data.
985 return false;
986 }
987
988 return $encrypted_data;
989 }
990
991 /**
992 * Extracts the user ID from a token
993 *
994 * @since 1.14.2
995 *
996 * @param string $token the token used to make the request.
997 * @return string $the user id or `invalid` if user id not present.
998 */
999 public function get_user_id_from_token( $token ) {
1000 $user_id = 'invalid';
1001
1002 if ( $token ) {
1003 $parsed_token = explode( ':', wp_unslash( $token ) );
1004
1005 if ( isset( $parsed_token[2] ) && ctype_digit( $parsed_token[2] ) ) {
1006 $user_id = $parsed_token[2];
1007 }
1008 }
1009
1010 return $user_id;
1011 }
1012
1013 /**
1014 * Gets the reported errors stored in the database
1015 *
1016 * @since 1.14.2
1017 *
1018 * @return array $errors
1019 */
1020 public function get_stored_errors() {
1021
1022 $stored_errors = get_option( self::STORED_ERRORS_OPTION );
1023
1024 if ( ! is_array( $stored_errors ) ) {
1025 $stored_errors = array();
1026 }
1027
1028 $stored_errors = $this->garbage_collector( $stored_errors );
1029
1030 return $stored_errors;
1031 }
1032
1033 /**
1034 * Gets the verified errors stored in the database.
1035 *
1036 * This method retrieves only the errors that are actually stored in the database,
1037 * without applying any filters that might inject additional errors. This is used
1038 * internally by methods that need to modify and store the verified errors back
1039 * to the database to prevent accidentally persisting filtered/injected errors.
1040 *
1041 * @since 1.14.2
1042 *
1043 * @return array $errors
1044 */
1045 public function get_verified_errors() {
1046 $verified_errors = get_option( self::STORED_VERIFIED_ERRORS_OPTION );
1047
1048 if ( ! is_array( $verified_errors ) ) {
1049 $verified_errors = array();
1050 }
1051
1052 $verified_errors = $this->garbage_collector( $verified_errors );
1053
1054 return $verified_errors;
1055 }
1056
1057 /**
1058 * Removes expired errors from the array
1059 *
1060 * This method is called by get_stored_errors and get_verified errors and filters their result
1061 * Whenever a new error is stored to the database or verified, this will be triggered and the
1062 * expired error will be permanently removed from the database
1063 *
1064 * @since 1.14.2
1065 *
1066 * @param array $errors array of errors as stored in the database.
1067 * @return array
1068 */
1069 private function garbage_collector( $errors ) {
1070 foreach ( $errors as $error_code => $users ) {
1071 foreach ( $users as $user_id => $error ) {
1072 if ( empty( $error['timestamp'] ) || self::ERROR_LIFE_TIME < time() - (int) $error['timestamp'] ) {
1073 unset( $errors[ $error_code ][ $user_id ] );
1074 }
1075 }
1076 }
1077 // Clear empty error codes.
1078 $errors = array_filter(
1079 $errors,
1080 function ( $user_errors ) {
1081 return ! empty( $user_errors );
1082 }
1083 );
1084 return $errors;
1085 }
1086
1087 /**
1088 * Delete all stored and verified errors from the database
1089 *
1090 * @since 1.14.2
1091 *
1092 * @return void
1093 */
1094 public function delete_all_errors() {
1095 $this->delete_stored_errors();
1096 $this->delete_verified_errors();
1097
1098 // Invalidate cache since we deleted all errors
1099 $this->invalidate_displayable_errors_cache();
1100 }
1101
1102 /**
1103 * Delete all stored and verified API errors from the database, leave the non-API errors intact.
1104 *
1105 * Only 'xmlrpc' and 'rest' type errors are deleted. 'local_state' type errors are
1106 * deliberately kept: they describe local connection state (e.g. a missing owner token),
1107 * which a successful API request does not disprove.
1108 *
1109 * @since 1.54.0
1110 *
1111 * @return void
1112 */
1113 public function delete_all_api_errors() {
1114 $type_filter = function ( $errors ) {
1115 if ( is_array( $errors ) ) {
1116 foreach ( $errors as $key => $error ) {
1117 if ( ! empty( $error['error_type'] ) && in_array( $error['error_type'], array( self::ERROR_TYPE_XMLRPC, self::ERROR_TYPE_REST ), true ) ) {
1118 unset( $errors[ $key ] );
1119 }
1120 }
1121 }
1122
1123 return count( $errors ) ? $errors : null;
1124 };
1125
1126 $stored_errors = $this->get_stored_errors();
1127 if ( is_array( $stored_errors ) && count( $stored_errors ) ) {
1128 $stored_errors = array_filter( array_map( $type_filter, $stored_errors ) );
1129 if ( count( $stored_errors ) ) {
1130 update_option( static::STORED_ERRORS_OPTION, $stored_errors );
1131 } else {
1132 delete_option( static::STORED_ERRORS_OPTION );
1133 }
1134 }
1135
1136 $verified_errors = $this->get_verified_errors();
1137 if ( is_array( $verified_errors ) && count( $verified_errors ) ) {
1138 $verified_errors = array_filter( array_map( $type_filter, $verified_errors ) );
1139 if ( count( $verified_errors ) ) {
1140 update_option( static::STORED_VERIFIED_ERRORS_OPTION, $verified_errors );
1141 } else {
1142 delete_option( static::STORED_VERIFIED_ERRORS_OPTION );
1143 }
1144 }
1145
1146 // Invalidate cache since we may have deleted verified errors
1147 $this->invalidate_displayable_errors_cache();
1148 }
1149
1150 /**
1151 * Delete all stored and verified errors from the database and returns unfiltered value
1152 *
1153 * This is used to hook into a couple of filters that expect true to not short circuit the disconnection flow
1154 *
1155 * @since 8.9.0
1156 *
1157 * @param mixed $check The input sent by the filter.
1158 * @return boolean
1159 */
1160 public function delete_all_errors_and_return_unfiltered_value( $check ) {
1161 $this->delete_all_errors();
1162 return $check;
1163 }
1164
1165 /**
1166 * Delete the reported errors stored in the database
1167 *
1168 * @since 1.14.2
1169 *
1170 * @return boolean True, if option is successfully deleted. False on failure.
1171 */
1172 public function delete_stored_errors() {
1173 return delete_option( self::STORED_ERRORS_OPTION );
1174 }
1175
1176 /**
1177 * Delete the verified errors stored in the database
1178 *
1179 * @since 1.14.2
1180 *
1181 * @return boolean True, if option is successfully deleted. False on failure.
1182 */
1183 public function delete_verified_errors() {
1184 return delete_option( self::STORED_VERIFIED_ERRORS_OPTION );
1185 }
1186
1187 /**
1188 * Gets an error based on the nonce
1189 *
1190 * Receives a nonce and finds the related error.
1191 *
1192 * @since 1.14.2
1193 *
1194 * @param string $nonce The nonce created for the error we want to get.
1195 * @return null|array Returns the error array representation or null if error not found.
1196 */
1197 public function get_error_by_nonce( $nonce ) {
1198 $errors = $this->get_stored_errors();
1199 foreach ( $errors as $user_group ) {
1200 foreach ( $user_group as $error ) {
1201 if ( $error['nonce'] === $nonce ) {
1202 return $error;
1203 }
1204 }
1205 }
1206 return null;
1207 }
1208
1209 /**
1210 * Adds an error to the verified error list
1211 *
1212 * @since 1.14.2
1213 *
1214 * @param array $error The error array, as it was saved in the unverified errors list.
1215 * @return void
1216 */
1217 public function verify_error( $error ) {
1218
1219 $verified_errors = $this->get_verified_errors();
1220 $error_code = $error['error_code'];
1221 $user_id = $error['user_id'];
1222
1223 if ( ! isset( $verified_errors[ $error_code ] ) ) {
1224 $verified_errors[ $error_code ] = array();
1225 }
1226
1227 $verified_errors[ $error_code ][ $user_id ] = $error;
1228
1229 update_option( self::STORED_VERIFIED_ERRORS_OPTION, $verified_errors );
1230
1231 // Invalidate cache since we added a new verified error
1232 $this->invalidate_displayable_errors_cache();
1233 }
1234
1235 /**
1236 * Register REST API end point for error handling.
1237 *
1238 * @since 1.14.2
1239 *
1240 * @return void
1241 */
1242 public function register_verify_error_endpoint() {
1243 register_rest_route(
1244 'jetpack/v4',
1245 '/verify_xmlrpc_error',
1246 array(
1247 'methods' => \WP_REST_Server::CREATABLE,
1248 'callback' => array( $this, 'verify_xml_rpc_error' ),
1249 'permission_callback' => '__return_true',
1250 'args' => array(
1251 'nonce' => array(
1252 'required' => true,
1253 'type' => 'string',
1254 ),
1255 ),
1256 )
1257 );
1258 }
1259
1260 /**
1261 * Handles verification that a xml rpc error is legit and came from WordPres.com
1262 *
1263 * @since 1.14.2
1264 *
1265 * @param \WP_REST_Request $request The request sent to the WP REST API.
1266 *
1267 * @return boolean
1268 */
1269 public function verify_xml_rpc_error( \WP_REST_Request $request ) {
1270 $error = $this->get_error_by_nonce( $request['nonce'] );
1271
1272 if ( $error ) {
1273 $this->verify_error( $error );
1274 return new \WP_REST_Response( true, 200 );
1275 }
1276
1277 return new \WP_REST_Response( false, 200 );
1278 }
1279
1280 /**
1281 * Prints a generic error notice for all connection errors
1282 *
1283 * @since 8.9.0
1284 *
1285 * @return void
1286 */
1287 public function generic_admin_notice_error() {
1288 // do not add admin notice to the jetpack dashboard.
1289 global $pagenow;
1290 if ( 'admin.php' === $pagenow || isset( $_GET['page'] ) && 'jetpack' === $_GET['page'] ) { // phpcs:ignore
1291 return;
1292 }
1293
1294 if ( ! current_user_can( 'jetpack_connect' ) ) {
1295 return;
1296 }
1297
1298 /**
1299 * Filters the message to be displayed in the admin notices area when there's a connection error.
1300 *
1301 * By default we don't display any errors.
1302 *
1303 * Return an empty value to disable the message.
1304 *
1305 * @since 8.9.0
1306 *
1307 * @param string $message The error message.
1308 * @param array $errors The array of errors. See Automattic\Jetpack\Connection\Error_Handler for details on the array structure.
1309 */
1310 $message = apply_filters( 'jetpack_connection_error_notice_message', '', $this->get_displayable_errors() );
1311
1312 /**
1313 * Fires inside the admin_notices hook just before displaying the error message for a broken connection.
1314 *
1315 * If you want to disable the default message from being displayed, return an empty value in the jetpack_connection_error_notice_message filter.
1316 *
1317 * @since 8.9.0
1318 *
1319 * @param array $errors The array of errors. See Automattic\Jetpack\Connection\Error_Handler for details on the array structure.
1320 */
1321 do_action( 'jetpack_connection_error_notice', $this->get_displayable_errors() );
1322
1323 if ( empty( $message ) ) {
1324 return;
1325 }
1326
1327 wp_admin_notice(
1328 esc_html( $message ),
1329 array(
1330 'type' => 'error',
1331 'dismissible' => true,
1332 'additional_classes' => array( 'jetpack-message', 'jp-connect' ),
1333 'attributes' => array( 'style' => 'display:block !important;' ),
1334 )
1335 );
1336 }
1337
1338 /**
1339 * Check an outgoing signed request's response for errors, and store them if needed.
1340 *
1341 * This is the entry point of the outgoing-request error flow (flow 2 in the class
1342 * docblock). `Client::remote_request()` calls it after every outgoing signed request.
1343 * Errors captured here are stored directly as verified — the WP.com verification
1344 * round-trip used for incoming errors is unnecessary, because the error arrived in a
1345 * response to a request this site itself initiated and signed.
1346 *
1347 * Note: XML-RPC faults arrive as HTTP 200 responses with an XML body, so they are
1348 * invisible to this method — only errors surfaced at the HTTP level with a JSON error
1349 * envelope are captured. This is one of the reasons outgoing calls are being migrated
1350 * from XML-RPC to REST.
1351 *
1352 * @see wp_remote_request() For more information on the $http_response array format.
1353 * @param array|\WP_Error $http_response The response or WP_Error on failure.
1354 * @param array $auth_data Auth data, allowed keys: `token`, `timestamp`, `nonce`, `body-hash`.
1355 * @param string $url Request URL.
1356 * @param string $method Request method.
1357 * @param string $error_type The transport of the outgoing request: `ERROR_TYPE_XMLRPC` or `ERROR_TYPE_REST`.
1358 *
1359 * @return void
1360 */
1361 public function check_api_response_for_errors( $http_response, $auth_data, $url, $method, $error_type ) {
1362 if ( 200 === wp_remote_retrieve_response_code( $http_response ) || ! is_array( $auth_data ) || ! $url || ! $method ) {
1363 return;
1364 }
1365
1366 $body_raw = wp_remote_retrieve_body( $http_response );
1367 if ( ! $body_raw ) {
1368 return;
1369 }
1370
1371 $body = json_decode( $body_raw, true );
1372
1373 // Support both error envelopes: the legacy v1 JSON-API shape (`error`) and the
1374 // WP-API v2 shape (`code`), the latter used by `wpcom/v2` endpoints such as
1375 // `jetpack-wpcom-user-data`. Prefer `error` for backwards compatibility.
1376 $error_code = is_array( $body ) ? ( $body['error'] ?? $body['code'] ?? null ) : null;
1377
1378 if ( empty( $error_code ) || ( ! is_string( $error_code ) && ! is_int( $error_code ) ) ) {
1379 return;
1380 }
1381
1382 $error = self::build_connection_wp_error(
1383 (string) $error_code,
1384 empty( $body['message'] ) ? '' : $body['message'],
1385 array(
1386 'token' => empty( $auth_data['token'] ) ? '' : $auth_data['token'],
1387 'timestamp' => empty( $auth_data['timestamp'] ) ? '' : $auth_data['timestamp'],
1388 'nonce' => empty( $auth_data['nonce'] ) ? '' : $auth_data['nonce'],
1389 'body_hash' => empty( $auth_data['body_hash'] ) ? '' : $auth_data['body_hash'],
1390 'method' => $method,
1391 'url' => $url,
1392 ),
1393 $error_type,
1394 self::DIRECTION_OUTGOING
1395 );
1396
1397 $this->report_error( $error, false, true );
1398 }
1399
1400 /**
1401 * Determines whether external filters are applied to the get_displayable_errors method.
1402 *
1403 * @since 6.13.10
1404 *
1405 * @return bool True if external filters are applied, false otherwise.
1406 */
1407 private function has_external_filters() {
1408 return has_filter( 'jetpack_connection_get_verified_errors' ) &&
1409 $this->should_allow_error_filtering();
1410 }
1411
1412 /**
1413 * Invalidates the cached displayable errors
1414 *
1415 * @since 6.13.10
1416 *
1417 * @return void
1418 */
1419 private function invalidate_displayable_errors_cache() {
1420 $this->cached_displayable_errors = null;
1421 }
1422 }
1423