PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.2-a.1
Jetpack – WP Security, Backup, Speed, & Growth v16.2-a.1
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 days ago connectors 3 days ago health 3 days ago identity-crisis 1 month ago sso 1 month ago traits 8 months ago webhooks 8 months ago class-authorize-json-api.php 1 month ago class-client.php 3 days ago class-connection-assets.php 1 year ago class-connection-notice.php 8 months ago class-error-handler.php 3 days ago class-external-storage.php 4 months ago class-heartbeat.php 1 month ago class-initial-state.php 3 weeks ago class-manager.php 3 days ago class-nonce-handler.php 8 months ago class-package-version-tracker.php 1 month ago class-package-version.php 3 days ago class-partner-coupon.php 2 months ago class-partner.php 2 years ago class-plugin-storage.php 8 months ago class-plugin.php 8 months ago class-rest-authentication.php 3 days ago class-rest-connector.php 3 days ago class-secrets.php 8 months ago class-server-sandbox.php 2 months ago class-site-health.php 3 days ago class-terms-of-service.php 3 days ago class-tokens-locks.php 8 months ago class-tokens.php 3 days ago class-tracking.php 3 days ago class-urls.php 6 months ago class-user-account-status.php 3 days 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 8 months ago interface-manager.php 4 years ago interface-storage-provider.php 6 months ago
class-error-handler.php
1961 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 in 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 points: `check_api_response_for_errors()`,
30 * `check_signed_request_for_errors()`, and `check_xmlrpc_fault_for_errors()`.
31 *
32 * 1. Every signed request made through `Client::remote_request()` has its response checked
33 * by `check_api_response_for_errors()`. A request that could not be signed at all never
34 * gets a response, so `Client::remote_request()` passes the signing failure to
35 * `check_signed_request_for_errors()` instead. An XML-RPC fault arrives as an HTTP 200
36 * response with an XML body, so it never reaches `check_api_response_for_errors()` either
37 * (which returns immediately on a 200 and decodes the body as JSON); `Jetpack_IXR_Client::query()`
38 * calls `check_xmlrpc_fault_for_errors()` directly from its fault branch instead.
39 * 2. When the response (or the signing failure, or the fault) carries a known error code, the
40 * error is stored and immediately marked verified (the same hourly gate applies). The
41 * WP.com verification round-trip of flow 1 is skipped because the error arrived in a
42 * response to a request this site itself initiated and signed — the failed response is its
43 * own evidence — or, for signing failures, because the evidence is the site's own state.
44 *
45 * Stored errors carry two orthogonal classification fields:
46 *
47 * - `error_type` — the transport/source of the failed request: 'xmlrpc', 'rest',
48 * 'local_state' (connection-state errors that a successful outgoing request cannot
49 * disprove — e.g. `invalid_connection_owner`, or WP.com being blocked from reaching
50 * this site; stored as 'connection' by package versions <= 8.8), or '' for entries
51 * stored by older package versions.
52 * - `error_direction` — 'incoming', 'outgoing', or '' (legacy entries and
53 * 'local_state'-type errors, which have no direction).
54 *
55 * Note on naming: both option names below contain "xmlrpc" because they predate REST
56 * support. They are intentionally kept as-is to avoid a data migration and breaking
57 * consumers that read the options directly — despite the names, they store errors of
58 * every type and direction.
59 *
60 * Errors are stored in the database as options in the following format:
61 *
62 * [
63 * $error_code => [
64 * $user_id => [
65 * $error_details
66 * ]
67 * ]
68 * ]
69 *
70 * For each error code we store a maximum of 5 errors for 5 different user ids.
71 *
72 * A user ID can be:
73 * * 0 for blog tokens
74 * * positive integer for user tokens
75 * * 'invalid' for malformed tokens
76 *
77 * Example error structure:
78 * [
79 * 'invalid_token' => [
80 * '123' => [
81 * 'error_code' => 'invalid_token',
82 * 'user_id' => '123',
83 * 'error_message' => 'The token is invalid',
84 * 'error_data' => ['action' => 'reconnect'],
85 * 'timestamp' => 1234567890,
86 * 'nonce' => 'abc123def',
87 * 'error_type' => 'xmlrpc',
88 * 'error_direction' => 'incoming'
89 * ]
90 * ]
91 * ]
92 *
93 * @since 1.14.2
94 */
95 class Error_Handler {
96
97 /**
98 * The name of the option that stores the errors
99 *
100 * @since 1.14.2
101 *
102 * @var string
103 */
104 const STORED_ERRORS_OPTION = 'jetpack_connection_xmlrpc_errors';
105
106 /**
107 * The name of the option that stores the errors
108 *
109 * @since 1.14.2
110 *
111 * @var string
112 */
113 const STORED_VERIFIED_ERRORS_OPTION = 'jetpack_connection_xmlrpc_verified_errors';
114
115 /**
116 * The prefix of the transient that controls the gate for each error code
117 *
118 * @since 1.14.2
119 *
120 * @var string
121 */
122 const ERROR_REPORTING_GATE = 'jetpack_connection_error_reporting_gate_';
123
124 /**
125 * `error_type` value for errors from XML-RPC requests.
126 *
127 * @since 8.9.0
128 *
129 * @var string
130 */
131 const ERROR_TYPE_XMLRPC = 'xmlrpc';
132
133 /**
134 * `error_type` value for errors from REST requests.
135 *
136 * @since 8.9.0
137 *
138 * @var string
139 */
140 const ERROR_TYPE_REST = 'rest';
141
142 /**
143 * `error_type` value for local connection-state errors that involve no request,
144 * e.g. `invalid_connection_owner`. The evidence for these errors is the site's own
145 * database, which is also why they carry no `error_direction`.
146 *
147 * Note: package versions <= 8.8 stored these errors with the type 'connection'.
148 *
149 * @since 8.9.0
150 *
151 * @var string
152 */
153 const ERROR_TYPE_LOCAL_STATE = 'local_state';
154
155 /**
156 * `error_direction` value for errors triggered by incoming requests (WP.com to this site).
157 *
158 * @since 8.9.0
159 *
160 * @var string
161 */
162 const DIRECTION_INCOMING = 'incoming';
163
164 /**
165 * `error_direction` value for errors triggered by outgoing requests (this site to WP.com).
166 *
167 * @since 8.9.0
168 *
169 * @var string
170 */
171 const DIRECTION_OUTGOING = 'outgoing';
172
173 /**
174 * Time in seconds a test should live in the database before being discarded
175 *
176 * @since 1.14.2
177 */
178 const ERROR_LIFE_TIME = DAY_IN_SECONDS;
179
180 /**
181 * List of known errors. Only error codes in this list will be handled
182 *
183 * @since 1.14.2
184 *
185 * @var array
186 */
187 public $known_errors = array(
188 // Incoming request token problems (Manager::internal_verify_xml_rpc_signature).
189 'malformed_user_id', // The user_id segment of the request token is not numeric.
190 'unknown_user', // The request token's user does not exist on this site.
191 // Incoming and outgoing token problems (Manager::internal_verify_xml_rpc_signature; Client::build_signed_request).
192 'malformed_token', // Request token is empty/garbled or version-mismatched (incoming); or the local token has no secret half (outgoing).
193 // Locally stored token problems (Tokens::get_access_token).
194 'no_user_tokens', // The user_tokens option is empty; no user tokens exist at all.
195 'empty_master_user_option', // The owner's token was requested but the master_user option is empty.
196 'no_token_for_user', // No stored token for the requested user.
197 'token_malformed', // The stored token for the requested user is corrupt (missing chunks).
198 'user_id_mismatch', // The requested user ID doesn't match the user_id segment of their stored token.
199 'no_possible_tokens', // No stored blog token.
200 'no_valid_user_token', // The stored user token doesn't match the key the request was signed with.
201 'no_valid_blog_token', // The stored blog token doesn't match the key the request was signed with.
202 'unknown_token', // No stored token matches the request token's key.
203 // Signature verification problems (Jetpack_Signature), or errors WPCOM returned
204 // for an outbound request (Error_Handler::check_api_response_for_errors,
205 // Error_Handler::check_xmlrpc_fault_for_errors).
206 'could_not_sign', // Signing the request failed for an unknown reason.
207 'invalid_scheme', // Invalid URL scheme when signing.
208 'unknown_scheme_port', // The URL scheme has no known port, so the signature cannot be built.
209 'invalid_secret', // The stored token secret is invalid.
210 'invalid_token', // No token available when signing; from WPCOM: the token used was rejected.
211 'token_mismatch', // The request token doesn't match the token we hold.
212 'invalid_body', // The request body is malformed.
213 'invalid_signature', // A signature parameter is malformed, or the timestamp is off (clock skew).
214 'invalid_body_hash', // The body hash doesn't match the request body.
215 'invalid_nonce', // The request nonce could not be added (likely a reuse/replay).
216 'signature_mismatch', // Computed signature differs: wrong secret, or URL/body drift (domain change, proxy).
217 // Connection state problems (Manager::get_connection_owner, Connection_Health_Tests).
218 'invalid_connection_owner', // The connection owner cannot be resolved: token missing or WP user deleted.
219 'xmlrpc_request_blocked', // WP.com reached the site but the request was rejected (firewall, WAF, or server rule).
220 );
221
222 /**
223 * Holds the instance of this singleton class
224 *
225 * @since 1.14.2
226 *
227 * @var Error_Handler $instance
228 */
229 public static $instance = null;
230
231 /**
232 * Cached displayable errors to avoid duplicate processing
233 *
234 * @since 6.13.10
235 *
236 * @var array|null
237 */
238 private $cached_displayable_errors = null;
239
240 /**
241 * Initialize instance, hooks and load verified errors handlers
242 *
243 * @since 1.14.2
244 */
245 private function __construct() {
246 defined( 'JETPACK__ERRORS_PUBLIC_KEY' ) || define( 'JETPACK__ERRORS_PUBLIC_KEY', 'KdZY80axKX+nWzfrOcizf0jqiFHnrWCl9X8yuaClKgM=' );
247
248 add_action( 'rest_api_init', array( $this, 'register_verify_error_endpoint' ) );
249
250 // Handle verified errors on admin pages.
251 add_action( 'admin_init', array( $this, 'handle_verified_errors' ) );
252
253 // If the site gets reconnected, clear errors.
254 add_action( 'jetpack_site_registered', array( $this, 'delete_all_errors' ) );
255 add_action( 'jetpack_get_site_data_success', array( $this, 'delete_all_api_errors' ) );
256 add_filter( 'jetpack_connection_disconnect_site_wpcom', array( $this, 'delete_all_errors_and_return_unfiltered_value' ) );
257 add_filter( 'jetpack_connection_delete_all_tokens', array( $this, 'delete_all_errors_and_return_unfiltered_value' ) );
258 add_action( 'jetpack_unlinked_user', array( $this, 'delete_all_errors' ) );
259 add_action( 'jetpack_updated_user_token', array( $this, 'delete_all_errors' ) );
260 }
261
262 /**
263 * Gets displayable errors with predefined structure and optional filtering.
264 *
265 * This method returns a hierarchical array of errors (error_code => user_id => error_details)
266 * that can be safely displayed in My Jetpack and other UI components. It includes
267 * predefined error messages and actions, with optional filtering for specific sites.
268 * Only processes a limited set of error codes that are meant to be displayed to users.
269 *
270 * error_data.action is only set when it deviates from the default behavior
271 * (e.g. 'none' to suppress the reconnect CTA); when absent, readers fall back
272 * to offering the reconnect CTA.
273 *
274 * @since 6.13.10
275 *
276 * @return array Array of displayable errors with hierarchical structure.
277 * Example:
278 * [
279 * 'invalid_token' => [
280 * '123' => [
281 * 'error_code' => 'invalid_token',
282 * 'user_id' => '123',
283 * 'error_message' => 'Your connection with WordPress.com seems to be broken...',
284 * 'audience' => 'user',
285 * 'error_data' => [...],
286 * 'timestamp' => 1234567890,
287 * 'nonce' => 'abc123def',
288 * 'error_type' => 'xmlrpc'
289 * ]
290 * ]
291 * ]
292 */
293 public function get_displayable_errors() {
294 $viewer_id = get_current_user_id();
295
296 // Check if we have a cached result for this viewer AND no filters are applied.
297 // The output is viewer-dependent (see audience classification below), so the
298 // cache is keyed by the current user.
299 if ( is_array( $this->cached_displayable_errors )
300 && array_key_exists( $viewer_id, $this->cached_displayable_errors )
301 && ! $this->has_external_filters() ) {
302 return $this->cached_displayable_errors[ $viewer_id ];
303 }
304
305 $verified_errors = $this->get_verified_errors();
306 $displayable_errors = array();
307
308 // The common case is zero verified errors: skip the owner/transferability
309 // lookups entirely then. The external filter below still runs so consumers
310 // (e.g. wpcomsh) can inject errors into an empty set.
311 if ( ! empty( $verified_errors ) ) {
312 $generic_message = __( "Your connection with WordPress.com seems to be broken. If you're experiencing issues, please try reconnecting.", 'jetpack-connection' );
313
314 $owner_id = (int) \Jetpack_Options::get_option( 'master_user' );
315 $viewer_is_owner = $owner_id > 0 && $viewer_id === $owner_id;
316 $is_transferable = ( new Manager() )->is_ownership_transferable();
317
318 foreach ( $verified_errors as $error_code => $users ) {
319 // Only process error codes that are meant to be displayed to users.
320 // A raw verified error whose code is marked non-displayable in
321 // get_error_display_configs() is never surfaced.
322 $display_config = $this->get_error_display_config( $error_code );
323 if ( null === $display_config ) {
324 continue;
325 }
326
327 foreach ( $users as $user_id => $error ) {
328 // An error that cannot be attributed to the blog token or to any user's
329 // token belongs to no audience and is not actionable by any viewer.
330 if ( 'invalid' === $user_id ) {
331 continue;
332 }
333
334 // An owner error attributed to someone who is no longer the connection
335 // owner describes a previous owner's token. Nobody can act on it.
336 // Only skip when there is a current owner to compare against.
337 if ( 'invalid_connection_owner' === $error_code
338 && $owner_id > 0
339 && (int) $user_id !== $owner_id ) {
340 continue;
341 }
342
343 $audience = $this->classify_error_audience( $user_id, $owner_id );
344
345 // A viewer is only ever shown errors for: their own user connection, the
346 // site connection, or the connection owner. Another (non-owner) user's
347 // broken token is invisible to everyone else, not just non-actionable.
348 // `invalid_connection_owner` is exempt: when there's no current owner to
349 // compare against, it falls back to 'user' audience by ID alone.
350 if ( 'user' === $audience
351 && (int) $user_id !== $viewer_id
352 && 'invalid_connection_owner' !== $error_code ) {
353 continue;
354 }
355
356 $message = $generic_message;
357 $action = null;
358
359 if ( isset( $display_config['message_callback'] ) ) {
360 $message = call_user_func( $display_config['message_callback'], $error );
361 }
362
363 // The owner reading their own missing-token error. The message callback
364 // has no viewer context, so it describes the owner in the third person —
365 // correct for every other reader, but stilted for the owner themselves.
366 // Only the missing-token flavor needs this: the deleted-WP-user flavor
367 // cannot be viewed by an owner who no longer exists.
368 if ( 'owner' === $audience
369 && $viewer_is_owner
370 && 'invalid_connection_owner' === $error_code
371 && ! ( $error['error_data']['has_user_token'] ?? true ) ) {
372 $message = __( 'You need to reconnect your WordPress.com account to restore the connection.', 'jetpack-connection' );
373 } elseif ( 'owner' === $audience && ! $viewer_is_owner ) {
374 // A secondary admin looking at the connection owner's token error. What
375 // they can usefully be told depends on whether ownership is transferable.
376 // Only name the owner, or describe what reconnecting would do, for
377 // viewers who can act on connection issues.
378 $viewer_can_connect = current_user_can( 'jetpack_connect' );
379 $owner_name = '';
380 if ( $viewer_can_connect ) {
381 $owner = get_userdata( $owner_id );
382 $owner_name = $owner instanceof \WP_User ? $owner->display_name : '';
383 }
384
385 if ( ! $is_transferable ) {
386 // Ownership is locked (a consumer declared it non-transferable).
387 // This admin cannot resolve the error themselves, so surface an
388 // informational notice naming the owner and offer no reconnect CTA.
389 $message = $owner_name
390 ? sprintf(
391 /* translators: %s is the display name of the Jetpack connection owner. */
392 __( 'The connection owner (%s) needs to reconnect their WordPress.com account to restore the connection.', 'jetpack-connection' ),
393 $owner_name
394 )
395 : __( 'The connection owner needs to reconnect their WordPress.com account to restore the connection.', 'jetpack-connection' );
396 $action = 'none';
397 } elseif ( $viewer_can_connect ) {
398 // Ownership is transferable, so the reconnect CTA stays available
399 // to this admin — but it is destructive in a way the generic copy
400 // doesn't convey. Manager::restore() branches on the *clicking*
401 // user's tokens, not on whose token the error describes.
402 $message = $owner_name
403 ? sprintf(
404 /* translators: %s is the display name of the Jetpack connection owner. */
405 __( 'The connection owner (%s) needs to reconnect their WordPress.com account to restore the connection. If you reconnect instead, you will become the new connection owner and every other user will be disconnected from WordPress.com.', 'jetpack-connection' ),
406 $owner_name
407 )
408 : __( 'The connection owner needs to reconnect their WordPress.com account to restore the connection. If you reconnect instead, you will become the new connection owner and every other user will be disconnected from WordPress.com.', 'jetpack-connection' );
409 }
410 }
411
412 $error['audience'] = $audience;
413 $error['error_message'] = $message;
414
415 // Only emit error_data.action when it deviates from the default. Readers
416 // already fall back to the reconnect CTA when no action is set, and
417 // injecting an explicit 'reconnect' could trip consumer code paths
418 // reserved for custom actions.
419 if ( null !== $action || ! empty( $display_config['support_link'] ) ) {
420 $error_data = ( isset( $error['error_data'] ) && is_array( $error['error_data'] ) ) ? $error['error_data'] : array();
421
422 if ( null !== $action ) {
423 $error_data['action'] = $action;
424 }
425
426 // Flags a reconnect-may-not-fix-it error so the notice offers a
427 // support link alongside the reconnect CTA. See `support_link` in
428 // get_error_display_configs().
429 if ( ! empty( $display_config['support_link'] ) ) {
430 $error_data['support_link'] = true;
431 }
432
433 $error['error_data'] = $error_data;
434 }
435
436 if ( ! isset( $displayable_errors[ $error_code ] ) ) {
437 $displayable_errors[ $error_code ] = array();
438 }
439 $displayable_errors[ $error_code ][ $user_id ] = $error;
440 }
441 }
442
443 // A broken connection owner outranks everything else in the set. Run this
444 // before the external filter below so consumer-injected errors are never
445 // dropped by it — they are the consumer's own state, not ours to rank.
446 $displayable_errors = $this->promote_owner_errors( $displayable_errors );
447 }
448
449 /**
450 * Filter displayable connection errors to allow customization of error messages and actions.
451 *
452 * This filter allows sites to customize how connection errors are displayed,
453 * including modifying error messages, actions, and data. Access to this filter
454 * is controlled by should_allow_error_filtering().
455 *
456 * Consumer-injected errors take precedence over the default state. They are not
457 * required to carry the newer `audience` field: it is optional metadata used
458 * only for our own audience-aware messaging, and any reader must treat a missing
459 * value as site-wide (`$error['audience'] ?? 'site'`).
460 *
461 * @since 6.12.0
462 *
463 * @param array $displayable_errors Array of displayable errors with hierarchical structure.
464 * @param array $verified_errors Array of raw verified errors from the database.
465 */
466 if ( $this->should_allow_error_filtering() ) {
467 $displayable_errors = apply_filters( 'jetpack_connection_get_verified_errors', $displayable_errors, $verified_errors );
468 }
469
470 // Only cache if no external filters are applied
471 if ( ! $this->has_external_filters() ) {
472 if ( ! is_array( $this->cached_displayable_errors ) ) {
473 $this->cached_displayable_errors = array();
474 }
475 $this->cached_displayable_errors[ $viewer_id ] = $displayable_errors;
476 }
477
478 return $displayable_errors;
479 }
480
481 /**
482 * Returns the display configuration for error codes that are meant to be
483 * displayed to users, keyed by error code.
484 *
485 * This is the whitelist consulted by get_displayable_errors(): a raw verified
486 * error whose code is not displayable here is never surfaced. Every code in
487 * `$known_errors` appears in get_error_display_configs(), non-displayable ones
488 * as `false` with the reason recorded alongside them.
489 *
490 * Copy is resolved at display time rather than stored with the error, so messages
491 * follow the viewer's locale and stay current across package updates. Adding a new
492 * error code means adding one entry to get_error_display_configs() — no branching
493 * in the display or notice paths.
494 *
495 * Everything here is display-time state that cannot be stored with the error:
496 * copy must resolve in each viewer's locale and follow current code, and the
497 * notice flags describe how this package renders, not the error itself. The
498 * error's *action* is deliberately NOT configured here — reporters declare it
499 * at creation time in `error_data['action']` (see `wp_error_to_array()`), since
500 * it is a stable machine token.
501 *
502 * Recognized keys, all optional:
503 * - `message_callback` (callable): receives the stored error array, returns the
504 * displayable message. Omit to keep the generic reconnect copy.
505 * - `default_admin_notice` (bool): when true, generic_admin_notice_error() shows
506 * this error's message even when no consumer supplies one via the
507 * `jetpack_connection_error_notice_message` filter (which still overrides).
508 * This is the only key that reaches beyond My Jetpack's own display: it opts
509 * the code into a site-wide wp-admin notice. Leave it unset unless the error
510 * genuinely needs that broader reach (see `xmlrpc_request_blocked` below for why).
511 * - `notice_link` (array): presentational `label` and `url` for a link appended to
512 * the default admin notice only. Only used when the notice shows this error's
513 * default message (a filtered message keeps full control of the copy).
514 * - `survives_owner_promotion` (bool): when true, this code is not dropped by
515 * promote_owner_errors() while the connection owner's own connection is broken.
516 * Set it only for a code that is not a token problem, and so is not waiting on
517 * the owner's reconnect to become actionable. Setting it does not make the code
518 * trigger that reduction — it only exempts it from one.
519 * - `support_link` (bool): when true, `error_data['support_link']` is set on the
520 * displayable error, and My Jetpack's notice appends a "Contact Jetpack
521 * Support" link next to the reconnect CTA. Set it only where reconnecting is
522 * not reliably the fix, so the viewer has somewhere else to go.
523 *
524 * @since 8.10.0
525 * @since 8.11.0 Merged with the former hardcoded list in get_displayable_errors():
526 * this method is now also the whitelist, not just the source of overrides.
527 *
528 * @param string $error_code The error code.
529 * @return array|null Display configuration, or null if this code is not displayable.
530 */
531 private function get_error_display_config( $error_code ) {
532 $config = $this->get_error_display_configs()[ $error_code ] ?? false;
533
534 return false === $config ? null : $config;
535 }
536
537 /**
538 * Returns the display disposition of every code in `$known_errors`, keyed by
539 * error code: an array of display configuration for a displayable code, or
540 * `false` for one that is never surfaced to users.
541 *
542 * Kept in the same order as `$known_errors` so the two read side by side, and
543 * covering every code rather than only the displayable ones.
544 *
545 * Split out from get_error_display_config() so the full set can be enumerated
546 * without invoking that method once per known error code.
547 *
548 * @since 8.11.0
549 *
550 * @return array Display configuration (array) or `false`, keyed by error code.
551 */
552 private function get_error_display_configs() {
553 static $configs = null;
554
555 if ( null !== $configs ) {
556 return $configs;
557 }
558
559 // What each code means is documented once, on `$known_errors`. The comments
560 // here record only the display decision, and only where it isn't obvious: an
561 // uncommented `array()` is a broken token that reconnecting fixes, which is
562 // what the generic copy already says.
563 $configs = array(
564 // Attacker-controllable garbage in an incoming request. Nothing about this
565 // site's own connection is wrong.
566 'malformed_user_id' => false,
567 // Expected after a user is deleted, and the owner flavor is covered by
568 // invalid_connection_owner. Incoming reports also drive WP.com-side
569 // self-healing, so a notice would surface a problem already resolving itself.
570 'unknown_user' => false,
571 'malformed_token' => array(),
572 // Never connecting a WordPress.com account is expected, not broken. The owner
573 // flavor is covered by invalid_connection_owner.
574 'no_user_tokens' => false,
575 // Same, for a site that has never had an owner. invalid_connection_owner
576 // covers the case where there was one and it broke.
577 'empty_master_user_option' => false,
578 // As no_user_tokens, for a single requested user.
579 'no_token_for_user' => false,
580 'token_malformed' => array(),
581 // Corrupt local token data, but for one user only, and the
582 // no_valid_user_token/token_malformed pair surfaces it when it actually
583 // blocks a request.
584 'user_id_mismatch' => false,
585 'no_possible_tokens' => array(),
586 'no_valid_user_token' => array(),
587 'no_valid_blog_token' => array(),
588 'unknown_token' => array(),
589 'could_not_sign' => array(),
590 // Both are about the URL being signed, not the connection: a code bug or an
591 // exotic site URL, which reconnecting does not change.
592 'invalid_scheme' => false,
593 'unknown_scheme_port' => false,
594 // Corrupt local token data like token_malformed above, caught at signing time
595 // rather than lookup time. Reconnect fixes it the same way.
596 'invalid_secret' => array(),
597 'invalid_token' => array(),
598 'token_mismatch' => array(),
599 // Per-request and transport-level, so unaffected by the state of the connection.
600 'invalid_body' => false,
601 // Environmental in both directions — a malformed parameter or clock skew,
602 // neither of which a reconnect fixes.
603 'invalid_signature' => false,
604 // Something altered the request in transit. Not a token problem, and
605 // signature_mismatch carries the same diagnosis with usable copy.
606 'invalid_body_hash' => false,
607 // A replay, or object-cache trouble. Self-resolving per request.
608 'invalid_nonce' => false,
609 // Ambiguous cause: could be a genuine secret desync (reconnect fixes it) or a
610 // proxy/CDN/WAF/security plugin altering the request in transit (reconnect
611 // doesn't help). Uses the generic message — support_link offers an
612 // alternative either way.
613 'signature_mismatch' => array(
614 'support_link' => true,
615 ),
616 // Two flavors with different remedies — see
617 // get_invalid_connection_owner_message().
618 'invalid_connection_owner' => array(
619 'message_callback' => array( $this, 'get_invalid_connection_owner_message' ),
620 ),
621 // The token can be perfectly valid here: the site is rejecting WordPress.com's
622 // requests, so a reconnect would be rejected the same way. The callback
623 // suppresses the reconnect CTA and names the real cause, staying brief because
624 // Site Health holds the full diagnosis. Ships a default admin notice because
625 // no other detection path can see this — WP.com's requests never arrive. And
626 // it outlives a broken owner, whose reconnect the same rule would block.
627 'xmlrpc_request_blocked' => array(
628 'message_callback' => array( $this, 'get_blocked_request_message' ),
629 'default_admin_notice' => true,
630 'survives_owner_promotion' => true,
631 'notice_link' => array(
632 'label' => __( 'Visit Site Health', 'jetpack-connection' ),
633 'url' => admin_url( 'site-health.php' ),
634 ),
635 ),
636 );
637
638 return $configs;
639 }
640
641 /**
642 * Builds the displayable message for the invalid-connection-owner error.
643 *
644 * `has_user_token` (set in Manager::get_connection_owner(), carried through into
645 * `error_data` by wp_error_to_array()) distinguishes the two flavors:
646 * - false: the owner's user token is simply missing — they still exist as a
647 * WP user, so reconnecting as them restores the connection.
648 * - true: the token is there, but the WP user it points at was deleted from
649 * this site. Nobody can reconnect as a user who no longer exists —
650 * reconnecting here means a different admin becoming the new owner, not
651 * the original owner logging back in.
652 *
653 * @since 8.11.0
654 *
655 * @param array $error The stored error array.
656 * @return string The message.
657 */
658 private function get_invalid_connection_owner_message( $error ) {
659 if ( ! ( $error['error_data']['has_user_token'] ?? true ) ) {
660 return __( 'The connection owner needs to reconnect their WordPress.com account to restore the connection.', 'jetpack-connection' );
661 }
662
663 return __( 'The WordPress.com account for this connection no longer exists on this site. An administrator needs to reconnect to become the new connection owner.', 'jetpack-connection' );
664 }
665
666 /**
667 * Builds the displayable message for the blocked-request error.
668 *
669 * Deliberately brief: Site Health holds the detailed diagnosis (including the
670 * HTTP status the site returned) and the resolution steps, so the message only
671 * names the condition and points there.
672 *
673 * @since 8.10.0
674 *
675 * @param array $error The stored error array (unused; part of the message_callback contract).
676 * @return string The message.
677 */
678 private function get_blocked_request_message( $error ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
679 return __( 'WordPress.com requests to your site are being blocked, usually by a firewall or security rule. See Site Health for details and next steps.', 'jetpack-connection' );
680 }
681
682 /**
683 * Classifies the audience of a stored connection error based on its user ID.
684 *
685 * The audience determines who a connection error is relevant to and, in turn,
686 * how it should be surfaced:
687 * - `site` : blog-token / site-wide errors (user ID `0`).
688 * - `owner` : errors tied to the connection owner's user token.
689 * - `user` : errors tied to a specific (non-owner) user's token.
690 *
691 * Unattributable errors (user ID 'invalid') are skipped by the display pipeline
692 * before classification, so this method only receives numeric user IDs.
693 *
694 * @since 8.8.0
695 *
696 * @param string|int $user_id The user ID associated with the error (`0` or a positive integer).
697 * @param int $owner_id The local user ID of the connection owner, or 0 if there is none.
698 * @return string One of 'site', 'owner', or 'user'.
699 */
700 private function classify_error_audience( $user_id, $owner_id ) {
701 $user_id = (int) $user_id;
702
703 if ( 0 === $user_id ) {
704 return 'site';
705 }
706
707 if ( $owner_id > 0 && $user_id === $owner_id ) {
708 return 'owner';
709 }
710
711 return 'user';
712 }
713
714 /**
715 * Reduces a set of displayable errors to the connection-owner ones when the
716 * owner's own connection is broken.
717 *
718 * The connection owner is the account every other connection on the site hangs
719 * off. While it is broken, no other error in the set is independently
720 * actionable.
721 *
722 * Two shapes count as a broken owner:
723 * - any error classified with the `owner` audience, i.e. attributed to the
724 * current owner's user ID; and
725 * - `invalid_connection_owner` at any audience — when there is no current owner
726 * to compare a user ID against, classify_error_audience() falls back to
727 * `user`, but the code itself already says the owner cannot be resolved.
728 *
729 * A code whose display config sets `survives_owner_promotion` is kept regardless.
730 * The premise above holds for token errors, whose one remedy is a reconnect the
731 * owner has to perform first — see that key's documentation on
732 * get_error_display_config() for when it doesn't.
733 *
734 * @since 8.11.0
735 *
736 * @param array $displayable_errors Displayable errors, keyed by error code then user ID.
737 * @return array The owner-only subset when the owner is broken, otherwise the input unchanged.
738 */
739 private function promote_owner_errors( array $displayable_errors ) {
740 $owner_errors = array();
741 $has_owner_error = false;
742
743 foreach ( $displayable_errors as $error_code => $users ) {
744 // Errors injected by a consumer through the filter that runs after this
745 // reduction have no config of ours; anything reaching here without one is
746 // treated as ordinary.
747 $display_config = $this->get_error_display_config( $error_code );
748 $survives = null !== $display_config && ! empty( $display_config['survives_owner_promotion'] );
749
750 foreach ( $users as $user_id => $error ) {
751 $is_owner_error = 'owner' === ( $error['audience'] ?? '' )
752 || 'invalid_connection_owner' === $error_code;
753
754 if ( ! $is_owner_error && ! $survives ) {
755 continue;
756 }
757
758 $owner_errors[ $error_code ][ $user_id ] = $error;
759
760 // An exempt error is not itself a broken owner, so it must not trigger the
761 // reduction on its own — only survive one triggered by something else.
762 $has_owner_error = $has_owner_error || $is_owner_error;
763 }
764 }
765
766 return $has_owner_error ? $owner_errors : $displayable_errors;
767 }
768
769 /**
770 * Sets up hooks for displaying verified errors on admin pages.
771 *
772 * This method is hooked into 'admin_init'. It retrieves displayable errors
773 * and, if any exist, sets up the necessary action and filter hooks to display
774 * them in admin notices and the React dashboard.
775 *
776 * @since 1.14.2
777 */
778 public function handle_verified_errors() {
779 $displayable_errors = $this->get_displayable_errors();
780
781 // If there are any displayable errors, set up the hooks for displaying them in React dashboard and admin notices.
782 if ( ! empty( $displayable_errors ) ) {
783 add_action( 'admin_notices', array( $this, 'generic_admin_notice_error' ) );
784 add_filter( 'react_connection_errors_initial_state', array( $this, 'jetpack_react_dashboard_error' ), 10, 1 );
785 }
786 }
787
788 /**
789 * Determines whether error filtering should be allowed.
790 *
791 * This method controls access to the jetpack_connection_displayable_errors filter.
792 * Currently, only WoA sites are allowed to use this filter.
793 *
794 * @since 6.13.10
795 *
796 * @return bool True if error filtering should be allowed, false otherwise.
797 */
798 protected function should_allow_error_filtering() {
799 $host = new \Automattic\Jetpack\Status\Host();
800 if ( $host->is_woa_site() || $host->is_vip_site() || $host->is_newspack_site() ) {
801 return true;
802 }
803
804 return false;
805 }
806
807 /**
808 * Provides displayable connection errors for the React dashboard in a flat array format.
809 *
810 * This method transforms the hierarchical displayable_errors structure into the flat format
811 * expected by the React dashboard. It's used as a filter for 'react_connection_errors_initial_state'.
812 * Returns only the first error to avoid overwhelming the user with multiple error messages.
813 *
814 * @since 8.9.0
815 *
816 * @param array $errors Existing errors from other filters (unused but required for filter signature).
817 * @return array Array containing only the first displayable error for the React dashboard.
818 * Example:
819 * [
820 * [
821 * 'code' => 'connection_error',
822 * 'message' => 'Your connection with WordPress.com seems to be broken...',
823 * 'action' => 'reconnect',
824 * 'data' => [
825 * 'api_error_code' => 'invalid_token',
826 * 'action' => 'reconnect',
827 * 'audience' => 'site' // Who the error is relevant to: 'site', 'owner', or 'user'.
828 * ]
829 * ]
830 * ]
831 */
832 public function jetpack_react_dashboard_error( $errors ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
833 $displayable_errors = $this->get_displayable_errors();
834
835 // Get the first error only
836 $first_error_code = array_key_first( $displayable_errors );
837 if ( ! $first_error_code ) {
838 return array(); // No errors
839 }
840
841 $first_user_errors = $displayable_errors[ $first_error_code ];
842 if ( ! is_array( $first_user_errors ) || empty( $first_user_errors ) ) {
843 return array(); // Invalid error structure
844 }
845
846 $first_error = reset( $first_user_errors );
847
848 // Validate error structure
849 if ( ! is_array( $first_error ) || ! isset( $first_error['error_message'] ) ) {
850 return array(); // Invalid error structure
851 }
852
853 // Determine the action - use the one from error_data if available, otherwise default to 'reconnect'
854 $action = 'reconnect'; // Default action for connection errors
855 if ( isset( $first_error['error_data']['action'] ) && is_string( $first_error['error_data']['action'] ) ) {
856 $action = $first_error['error_data']['action'];
857 }
858
859 // Safely merge error data, ensuring we don't overwrite critical fields
860 $error_data = isset( $first_error['error_data'] ) && is_array( $first_error['error_data'] ) ? $first_error['error_data'] : array();
861
862 // Build the data array with safe merging
863 $dashboard_data = array( 'api_error_code' => $first_error_code );
864
865 // Add error_data fields, but be careful not to overwrite api_error_code
866 foreach ( $error_data as $key => $value ) {
867 if ( 'api_error_code' !== $key ) {
868 $dashboard_data[ $key ] = $value;
869 }
870 }
871
872 // Expose the error audience (site/owner/user) so the dashboard can render
873 // audience-aware copy. Falls back to site-wide for consumer-injected errors
874 // that predate the audience field.
875 $dashboard_data['audience'] = $first_error['audience'] ?? 'site';
876
877 $dashboard_error = array(
878 array(
879 'code' => 'connection_error',
880 'message' => $first_error['error_message'],
881 'action' => $action,
882 'data' => $dashboard_data,
883 ),
884 );
885
886 return $dashboard_error;
887 }
888
889 /**
890 * Gets the instance of this singleton class
891 *
892 * @since 1.14.2
893 *
894 * @return Error_Handler $instance
895 */
896 public static function get_instance() {
897 if ( self::$instance === null ) {
898 self::$instance = new self();
899 }
900 return self::$instance;
901 }
902
903 /**
904 * Keep track of a connection error that was encountered
905 *
906 * This is the entry point of the incoming-request error flow (flow 1 in the class
907 * docblock) when called with `$skip_wpcom_verification = false` (the default).
908 *
909 * Only error codes present in `$known_errors` are handled; anything else is
910 * silently discarded. The `WP_Error` must carry the data shape produced by
911 * `build_connection_error_data()`, or it is discarded as well.
912 *
913 * @param \WP_Error $error The error object.
914 * @param boolean $force Force the report, even if should_report_error is false.
915 * @param boolean $skip_wpcom_verification Set to 'true' to verify the error locally and skip the WP.com
916 * verification round-trip. Only do this when the error is self-evidencing — e.g. it came
917 * from a response WP.com sent to a request this site initiated (the outgoing flow), or
918 * from local connection state. Skipping verification for an incoming request error would
919 * let any unauthenticated requester plant a verified error and trigger its workflows
920 * (admin notices, self-healing), so leave it 'false' for anything derived from an
921 * incoming request.
922 *
923 * @return void
924 * @since 1.14.2
925 */
926 public function report_error( \WP_Error $error, $force = false, $skip_wpcom_verification = false ) {
927 if ( in_array( $error->get_error_code(), $this->known_errors, true ) && ( $this->should_report_error( $error ) || $force ) ) {
928 $stored_error = $this->store_error( $error );
929 if ( $stored_error ) {
930 $skip_wpcom_verification ? $this->verify_error( $stored_error ) : $this->send_error_to_wpcom( $stored_error );
931 }
932 }
933 }
934
935 /**
936 * Checks the status of the gate
937 *
938 * This protects the site (and WPCOM) against over loads.
939 *
940 * @since 1.14.2
941 *
942 * @param \WP_Error $error the error object.
943 * @return boolean $should_report True if gate is open and the error should be reported.
944 */
945 public function should_report_error( \WP_Error $error ) {
946 if ( defined( '\\JETPACK_DEV_DEBUG' ) && constant( '\\JETPACK_DEV_DEBUG' ) ) {
947 return true;
948 }
949
950 /**
951 * Whether to bypass the gate for the error handling
952 *
953 * By default, we only process errors once an hour for each error code.
954 * This is done to avoid overflows. If you need to disable this gate, you can set this variable to true.
955 *
956 * This filter is useful for unit testing
957 *
958 * @since 1.14.2
959 *
960 * @param boolean $bypass_gate whether to bypass the gate. Default is false, do not bypass.
961 */
962 $bypass_gate = apply_filters( 'jetpack_connection_bypass_error_reporting_gate', false );
963 if ( true === $bypass_gate ) {
964 return true;
965 }
966
967 $transient = self::ERROR_REPORTING_GATE . $error->get_error_code();
968
969 if ( get_transient( $transient ) ) {
970 return false;
971 }
972
973 set_transient( $transient, true, HOUR_IN_SECONDS );
974 return true;
975 }
976
977 /**
978 * Stores the error in the database so we know there is an issue and can inform the user
979 *
980 * @since 1.14.2
981 *
982 * @param \WP_Error $error the error object.
983 * @return boolean|array False if stored errors were not updated and the error array if it was successfully stored.
984 */
985 public function store_error( \WP_Error $error ) {
986
987 $stored_errors = $this->get_stored_errors();
988 $error_array = $this->wp_error_to_array( $error );
989
990 if ( ! $error_array ) {
991 return false;
992 }
993
994 $error_code = $error->get_error_code();
995 $user_id = $error_array['user_id'];
996
997 if ( ! isset( $stored_errors[ $error_code ] ) || ! is_array( $stored_errors[ $error_code ] ) ) {
998 $stored_errors[ $error_code ] = array();
999 }
1000
1001 $stored_errors[ $error_code ][ $user_id ] = $error_array;
1002
1003 // Let's store a maximum of 5 different user ids for each error code.
1004 $error_code_count = is_countable( $stored_errors[ $error_code ] ) ? count( $stored_errors[ $error_code ] ) : 0;
1005 if ( $error_code_count > 5 ) {
1006 // array_shift will destroy keys here because they are numeric, so manually remove first item.
1007 $keys = array_keys( $stored_errors[ $error_code ] );
1008 unset( $stored_errors[ $error_code ][ $keys[0] ] );
1009 }
1010
1011 if ( update_option( self::STORED_ERRORS_OPTION, $stored_errors ) ) {
1012 return $error_array;
1013 }
1014
1015 return false;
1016 }
1017
1018 /**
1019 * Builds action error data for generic JavaScript components.
1020 *
1021 * This helper method creates standardized error_data arrays that work with the generic
1022 * JavaScript error handling components. External plugins (like wpcomsh) can use this
1023 * to ensure their error structures are compatible.
1024 *
1025 * @since 6.16.0
1026 *
1027 * @param array $args Action configuration arguments - only non-empty values will be included.
1028 * @return array Standardized error_data array for JavaScript components.
1029 */
1030 public function build_action_error_data( array $args = array() ) {
1031 // Set default values for variants
1032 $args = wp_parse_args(
1033 $args,
1034 array(
1035 'action_variant' => 'primary',
1036 'secondary_action_variant' => 'secondary',
1037 )
1038 );
1039
1040 // Start with core data
1041 $error_data = array(
1042 'blog_id' => \Jetpack_Options::get_option( 'id' ),
1043 );
1044
1045 // Validate variant values
1046 $valid_variants = array( 'primary', 'secondary' );
1047 if ( ! in_array( $args['action_variant'], $valid_variants, true ) ) {
1048 $args['action_variant'] = 'primary';
1049 }
1050 if ( ! in_array( $args['secondary_action_variant'], $valid_variants, true ) ) {
1051 $args['secondary_action_variant'] = 'secondary';
1052 }
1053
1054 // Merge extra_data first, then regular args (so args take precedence)
1055 if ( ! empty( $args['extra_data'] ) && is_array( $args['extra_data'] ) ) {
1056 $error_data = array_merge( $error_data, $args['extra_data'] );
1057 unset( $args['extra_data'] ); // Remove from args to avoid duplication
1058 }
1059
1060 // Filter out empty values and merge with error_data
1061 $filtered_args = array_filter(
1062 $args,
1063 function ( $value ) {
1064 return ! empty( $value );
1065 }
1066 );
1067
1068 return array_merge( $error_data, $filtered_args );
1069 }
1070
1071 /**
1072 * Builds a standardized error array for the connection error system.
1073 *
1074 * This method creates a consistent error array structure that can be used
1075 * by both internal error handling and external plugins/customizations.
1076 *
1077 * @since 1.14.2
1078 * @since 8.9.0 Added the `$error_direction` parameter and output field.
1079 *
1080 * @param string $error_code The error code identifier.
1081 * @param string $error_message The human-readable error message.
1082 * @param array $error_data Additional error data (optional).
1083 * @param string $user_id The user ID associated with the error (optional).
1084 * @param string $error_type The type of error (optional). One of the `ERROR_TYPE_*` constants or ''.
1085 * @param string $error_direction The direction of the request that triggered the error (optional).
1086 * One of the `DIRECTION_*` constants or ''.
1087 * @return array|false The standardized error array or false on failure.
1088 * Example successful return:
1089 * [
1090 * 'error_code' => 'invalid_token',
1091 * 'user_id' => '123',
1092 * 'error_message' => 'The token is invalid',
1093 * 'error_data' => ['action' => 'reconnect'],
1094 * 'timestamp' => 1234567890,
1095 * 'nonce' => 'abc123def',
1096 * 'error_type' => 'xmlrpc',
1097 * 'error_direction' => 'incoming'
1098 * ]
1099 */
1100 public function build_error_array( string $error_code, string $error_message, array $error_data = array(), $user_id = '0', string $error_type = '', string $error_direction = '' ) {
1101 // Validate required parameters
1102 if ( empty( $error_code ) || empty( $error_message ) ) {
1103 return false;
1104 }
1105
1106 // Validate user_id is a string or integer
1107 if ( ! is_string( $user_id ) && ! is_int( $user_id ) ) {
1108 return false;
1109 }
1110
1111 return array(
1112 'error_code' => $error_code,
1113 'user_id' => $user_id,
1114 'error_message' => $error_message,
1115 'error_data' => $error_data,
1116 'timestamp' => time(),
1117 'nonce' => wp_generate_password( 10, false ),
1118 'error_type' => $error_type,
1119 'error_direction' => $error_direction,
1120 );
1121 }
1122
1123 /**
1124 * Builds the standardized `WP_Error` data payload for a connection error.
1125 *
1126 * This is the single place the error-data contract consumed by `wp_error_to_array()`
1127 * is defined. Use it (or `build_connection_wp_error()`) instead of assembling the
1128 * data array by hand, so every reporter produces the same shape:
1129 *
1130 * - `signature_details` is guaranteed to contain a `token` key (empty string when
1131 * the error is not tied to a specific token), which `wp_error_to_array()` requires.
1132 * The token is also what WP.com checks when verifying incoming-flow errors, so its
1133 * key must not be renamed.
1134 * - `error_type` and `error_direction` are validated against the class constants and
1135 * stored as '' when the given value is not recognized. For 'local_state' errors the
1136 * direction is always forced to '' — they describe the site's own database, not a
1137 * request, so a direction would be meaningless and is ignored if passed.
1138 * - `$extra` cannot override the reserved keys: `signature_details`, `error_type`,
1139 * and `error_direction` always win the merge.
1140 *
1141 * @since 8.9.0
1142 *
1143 * @param array $signature_details Details of the signed request that failed: `token`,
1144 * and typically `timestamp`, `nonce`, `body_hash`,
1145 * `method`, `url`.
1146 * @param string $error_type One of the `ERROR_TYPE_*` constants.
1147 * @param string $error_direction One of the `DIRECTION_*` constants. Ignored for
1148 * 'local_state' errors, which have no direction.
1149 * @param array $extra Optional additional data, e.g. a `user_id` fallback for
1150 * errors whose token cannot be attributed to a user, or
1151 * `has_user_token` for `invalid_connection_owner`.
1152 * @return array The error data array to pass as the third argument of `WP_Error`.
1153 */
1154 public static function build_connection_error_data( array $signature_details, string $error_type, string $error_direction, array $extra = array() ) {
1155 $valid_types = array( self::ERROR_TYPE_XMLRPC, self::ERROR_TYPE_REST, self::ERROR_TYPE_LOCAL_STATE );
1156 $valid_directions = array( self::DIRECTION_INCOMING, self::DIRECTION_OUTGOING );
1157
1158 $error_type = in_array( $error_type, $valid_types, true ) ? $error_type : '';
1159
1160 if ( self::ERROR_TYPE_LOCAL_STATE === $error_type ) {
1161 $error_direction = '';
1162 } else {
1163 $error_direction = in_array( $error_direction, $valid_directions, true ) ? $error_direction : '';
1164 }
1165
1166 return array_merge(
1167 $extra,
1168 array(
1169 'signature_details' => array_merge( array( 'token' => '' ), $signature_details ),
1170 'error_type' => $error_type,
1171 'error_direction' => $error_direction,
1172 )
1173 );
1174 }
1175
1176 /**
1177 * Builds a `WP_Error` carrying the standardized connection error data.
1178 *
1179 * Convenience wrapper around `build_connection_error_data()` — see it for the
1180 * data contract. All connection error reporters should create their `WP_Error`
1181 * objects through this factory.
1182 *
1183 * @since 8.9.0
1184 *
1185 * @param string $error_code The error code, ideally one of `$known_errors`.
1186 * @param string $error_message The human-readable error message. `build_error_array()` rejects an
1187 * empty message, so a generic fallback is substituted when this is ''.
1188 * @param array $signature_details Details of the signed request that failed. See `build_connection_error_data()`.
1189 * @param string $error_type One of the `ERROR_TYPE_*` constants.
1190 * @param string $error_direction One of the `DIRECTION_*` constants, or '' for errors with no direction.
1191 * @param array $extra Optional additional data. See `build_connection_error_data()`.
1192 * @return \WP_Error
1193 */
1194 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() ) {
1195 return new \WP_Error(
1196 $error_code,
1197 '' === $error_message ? __( 'An error occurred with the connection.', 'jetpack-connection' ) : $error_message,
1198 self::build_connection_error_data( $signature_details, $error_type, $error_direction, $extra )
1199 );
1200 }
1201
1202 /**
1203 * Converts a WP_Error object in the array representation we store in the database
1204 *
1205 * The `WP_Error` data must follow the contract defined by `build_connection_error_data()`:
1206 * a `signature_details` array containing at least a `token` key is required, and this
1207 * method returns false without storing anything when it is absent. `error_type` and
1208 * `error_direction` are read from the data and stored as '' when missing.
1209 *
1210 * The user attribution comes from the token in `signature_details`, which identifies
1211 * the exact credential that failed. An explicit `user_id` in the error data is only
1212 * consulted as a fallback when the token yields no user (e.g. non-signature errors
1213 * such as `invalid_connection_owner`, which are reported with an empty token).
1214 *
1215 * @since 1.14.2
1216 *
1217 * @param \WP_Error $error the error object.
1218 * @return boolean|array False if error is invalid or the error array
1219 */
1220 public function wp_error_to_array( \WP_Error $error ) {
1221
1222 $data = $error->get_error_data();
1223
1224 if ( ! isset( $data['signature_details'] ) || ! is_array( $data['signature_details'] ) ) {
1225 return false;
1226 }
1227
1228 $signature_details = $data['signature_details'];
1229
1230 if ( ! isset( $signature_details['token'] ) ) {
1231 return false;
1232 }
1233
1234 $user_id = $this->get_user_id_from_token( $signature_details['token'] );
1235
1236 if ( 'invalid' === $user_id && isset( $data['user_id'] ) && is_numeric( $data['user_id'] ) ) {
1237 $user_id = (string) (int) $data['user_id'];
1238 }
1239
1240 $error_data = $signature_details;
1241
1242 // For invalid_connection_owner, has_user_token distinguishes a missing owner
1243 // token from a deleted owner WP user. Keep it so display code can tell the
1244 // two flavors apart.
1245 if ( isset( $data['has_user_token'] ) ) {
1246 $error_data['has_user_token'] = (bool) $data['has_user_token'];
1247 }
1248
1249 // For xmlrpc_request_blocked, the HTTP status the site returned to WP.com
1250 // (e.g. 403). Keep it so display code can include it in the message.
1251 if ( isset( $data['site_http_status'] ) ) {
1252 $error_data['site_http_status'] = (int) $data['site_http_status'];
1253 }
1254
1255 // The display action declared by the reporter at creation time, e.g. 'none'
1256 // to suppress the reconnect CTA. Only our own reporters set this (it is never
1257 // derived from request data); readers treat a missing action as 'reconnect'.
1258 if ( isset( $data['action'] ) && is_string( $data['action'] ) ) {
1259 $error_data['action'] = $data['action'];
1260 }
1261
1262 return $this->build_error_array(
1263 $error->get_error_code(),
1264 $error->get_error_message(),
1265 $error_data,
1266 $user_id,
1267 empty( $data['error_type'] ) ? '' : $data['error_type'],
1268 empty( $data['error_direction'] ) ? '' : $data['error_direction']
1269 );
1270 }
1271
1272 /**
1273 * Sends the error to WP.com to be verified
1274 *
1275 * @since 1.14.2
1276 *
1277 * @param array $error_array The array representation of the error as it is stored in the database.
1278 * @return bool
1279 */
1280 public function send_error_to_wpcom( $error_array ) {
1281
1282 $blog_id = \Jetpack_Options::get_option( 'id' );
1283
1284 $encrypted_data = $this->encrypt_data_to_wpcom( $error_array );
1285
1286 if ( false === $encrypted_data ) {
1287 return false;
1288 }
1289
1290 $args = array(
1291 'body' => array(
1292 'error_data' => $encrypted_data,
1293 ),
1294 );
1295
1296 // send encrypted data to WP.com Public-API v2.
1297 wp_remote_post( "https://public-api.wordpress.com/wpcom/v2/sites/{$blog_id}/jetpack-report-error/", $args );
1298 return true;
1299 }
1300
1301 /**
1302 * Encrypt data to be sent over to WP.com
1303 *
1304 * @since 1.14.2
1305 *
1306 * @param array|string $data the data to be encoded.
1307 * @return boolean|string The encoded string on success, false on failure
1308 */
1309 public function encrypt_data_to_wpcom( $data ) {
1310
1311 try {
1312 // phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
1313 // phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
1314 $encrypted_data = base64_encode( sodium_crypto_box_seal( wp_json_encode( $data, JSON_UNESCAPED_SLASHES ), base64_decode( JETPACK__ERRORS_PUBLIC_KEY ) ) );
1315 // phpcs:enable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
1316 // phpcs:enable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
1317 } catch ( \SodiumException $e ) {
1318 // error encrypting data.
1319 return false;
1320 }
1321
1322 return $encrypted_data;
1323 }
1324
1325 /**
1326 * Extracts the user ID from a token
1327 *
1328 * @since 1.14.2
1329 *
1330 * @param string $token the token used to make the request.
1331 * @return string $the user id or `invalid` if user id not present.
1332 */
1333 public function get_user_id_from_token( $token ) {
1334 $user_id = 'invalid';
1335
1336 if ( $token ) {
1337 $parsed_token = explode( ':', wp_unslash( $token ) );
1338
1339 if ( isset( $parsed_token[2] ) && ctype_digit( $parsed_token[2] ) ) {
1340 $user_id = $parsed_token[2];
1341 }
1342 }
1343
1344 return $user_id;
1345 }
1346
1347 /**
1348 * Gets the reported errors stored in the database
1349 *
1350 * @since 1.14.2
1351 *
1352 * @return array $errors
1353 */
1354 public function get_stored_errors() {
1355
1356 $stored_errors = get_option( self::STORED_ERRORS_OPTION );
1357
1358 if ( ! is_array( $stored_errors ) ) {
1359 $stored_errors = array();
1360 }
1361
1362 $stored_errors = $this->garbage_collector( $stored_errors );
1363
1364 return $stored_errors;
1365 }
1366
1367 /**
1368 * Gets the verified errors stored in the database.
1369 *
1370 * This method retrieves only the errors that are actually stored in the database,
1371 * without applying any filters that might inject additional errors. This is used
1372 * internally by methods that need to modify and store the verified errors back
1373 * to the database to prevent accidentally persisting filtered/injected errors.
1374 *
1375 * @since 1.14.2
1376 *
1377 * @return array $errors
1378 */
1379 public function get_verified_errors() {
1380 $verified_errors = get_option( self::STORED_VERIFIED_ERRORS_OPTION );
1381
1382 if ( ! is_array( $verified_errors ) ) {
1383 $verified_errors = array();
1384 }
1385
1386 $verified_errors = $this->garbage_collector( $verified_errors );
1387
1388 return $verified_errors;
1389 }
1390
1391 /**
1392 * Removes expired errors from the array
1393 *
1394 * This method is called by get_stored_errors and get_verified errors and filters their result
1395 * Whenever a new error is stored to the database or verified, this will be triggered and the
1396 * expired error will be permanently removed from the database
1397 *
1398 * @since 1.14.2
1399 *
1400 * @param array $errors array of errors as stored in the database.
1401 * @return array
1402 */
1403 private function garbage_collector( $errors ) {
1404 foreach ( $errors as $error_code => $users ) {
1405 foreach ( $users as $user_id => $error ) {
1406 if ( empty( $error['timestamp'] ) || self::ERROR_LIFE_TIME < time() - (int) $error['timestamp'] ) {
1407 unset( $errors[ $error_code ][ $user_id ] );
1408 }
1409 }
1410 }
1411 // Clear empty error codes.
1412 $errors = array_filter(
1413 $errors,
1414 function ( $user_errors ) {
1415 return ! empty( $user_errors );
1416 }
1417 );
1418 return $errors;
1419 }
1420
1421 /**
1422 * Delete all stored and verified errors from the database
1423 *
1424 * @since 1.14.2
1425 *
1426 * @return void
1427 */
1428 public function delete_all_errors() {
1429 $this->delete_stored_errors();
1430 $this->delete_verified_errors();
1431
1432 // Invalidate cache since we deleted all errors
1433 $this->invalidate_displayable_errors_cache();
1434 }
1435
1436 /**
1437 * Delete all stored and verified API errors from the database, leave the non-API errors intact.
1438 *
1439 * Only 'xmlrpc' and 'rest' type errors are deleted. 'local_state' type errors are
1440 * deliberately kept: they describe local connection state (e.g. a missing owner token),
1441 * which a successful API request does not disprove.
1442 *
1443 * @since 1.54.0
1444 *
1445 * @return void
1446 */
1447 public function delete_all_api_errors() {
1448 $type_filter = function ( $errors ) {
1449 if ( is_array( $errors ) ) {
1450 foreach ( $errors as $key => $error ) {
1451 if ( ! empty( $error['error_type'] ) && in_array( $error['error_type'], array( self::ERROR_TYPE_XMLRPC, self::ERROR_TYPE_REST ), true ) ) {
1452 unset( $errors[ $key ] );
1453 }
1454 }
1455 }
1456
1457 return count( $errors ) ? $errors : null;
1458 };
1459
1460 $stored_errors = $this->get_stored_errors();
1461 if ( is_array( $stored_errors ) && count( $stored_errors ) ) {
1462 $stored_errors = array_filter( array_map( $type_filter, $stored_errors ) );
1463 if ( count( $stored_errors ) ) {
1464 update_option( static::STORED_ERRORS_OPTION, $stored_errors );
1465 } else {
1466 delete_option( static::STORED_ERRORS_OPTION );
1467 }
1468 }
1469
1470 $verified_errors = $this->get_verified_errors();
1471 if ( is_array( $verified_errors ) && count( $verified_errors ) ) {
1472 $verified_errors = array_filter( array_map( $type_filter, $verified_errors ) );
1473 if ( count( $verified_errors ) ) {
1474 update_option( static::STORED_VERIFIED_ERRORS_OPTION, $verified_errors );
1475 } else {
1476 delete_option( static::STORED_VERIFIED_ERRORS_OPTION );
1477 }
1478 }
1479
1480 // Invalidate cache since we may have deleted verified errors
1481 $this->invalidate_displayable_errors_cache();
1482 }
1483
1484 /**
1485 * Delete all stored and verified errors from the database and returns unfiltered value
1486 *
1487 * This is used to hook into a couple of filters that expect true to not short circuit the disconnection flow
1488 *
1489 * @since 8.9.0
1490 *
1491 * @param mixed $check The input sent by the filter.
1492 * @return boolean
1493 */
1494 public function delete_all_errors_and_return_unfiltered_value( $check ) {
1495 $this->delete_all_errors();
1496 return $check;
1497 }
1498
1499 /**
1500 * Delete the reported errors stored in the database
1501 *
1502 * @since 1.14.2
1503 *
1504 * @return boolean True, if option is successfully deleted. False on failure.
1505 */
1506 public function delete_stored_errors() {
1507 return delete_option( self::STORED_ERRORS_OPTION );
1508 }
1509
1510 /**
1511 * Delete the verified errors stored in the database
1512 *
1513 * @since 1.14.2
1514 *
1515 * @return boolean True, if option is successfully deleted. False on failure.
1516 */
1517 public function delete_verified_errors() {
1518 return delete_option( self::STORED_VERIFIED_ERRORS_OPTION );
1519 }
1520
1521 /**
1522 * Deletes all stored and verified errors for a single error code.
1523 *
1524 * Used by self-healing flows that can positively confirm one specific error
1525 * condition is gone (e.g. a passing connection test clearing
1526 * `xmlrpc_request_blocked`) without touching unrelated errors.
1527 *
1528 * @since 8.10.0
1529 *
1530 * @param string $error_code The error code to delete.
1531 * @return bool True if any stored or verified error was deleted.
1532 */
1533 public function delete_error_by_code( $error_code ) {
1534 $deleted = false;
1535
1536 // Reopen the reporting gate for this code: deletion means the condition was
1537 // positively confirmed cleared, so a recurrence must be reportable immediately
1538 // rather than suppressed for up to an hour.
1539 delete_transient( self::ERROR_REPORTING_GATE . $error_code );
1540
1541 $stored_errors = $this->get_stored_errors();
1542 if ( isset( $stored_errors[ $error_code ] ) ) {
1543 unset( $stored_errors[ $error_code ] );
1544 $deleted = true;
1545 if ( count( $stored_errors ) ) {
1546 update_option( self::STORED_ERRORS_OPTION, $stored_errors );
1547 } else {
1548 delete_option( self::STORED_ERRORS_OPTION );
1549 }
1550 }
1551
1552 $verified_errors = $this->get_verified_errors();
1553 if ( isset( $verified_errors[ $error_code ] ) ) {
1554 unset( $verified_errors[ $error_code ] );
1555 $deleted = true;
1556 if ( count( $verified_errors ) ) {
1557 update_option( self::STORED_VERIFIED_ERRORS_OPTION, $verified_errors );
1558 } else {
1559 delete_option( self::STORED_VERIFIED_ERRORS_OPTION );
1560 }
1561 }
1562
1563 if ( $deleted ) {
1564 $this->invalidate_displayable_errors_cache();
1565 }
1566
1567 return $deleted;
1568 }
1569
1570 /**
1571 * Gets an error based on the nonce
1572 *
1573 * Receives a nonce and finds the related error.
1574 *
1575 * @since 1.14.2
1576 *
1577 * @param string $nonce The nonce created for the error we want to get.
1578 * @return null|array Returns the error array representation or null if error not found.
1579 */
1580 public function get_error_by_nonce( $nonce ) {
1581 $errors = $this->get_stored_errors();
1582 foreach ( $errors as $user_group ) {
1583 foreach ( $user_group as $error ) {
1584 if ( $error['nonce'] === $nonce ) {
1585 return $error;
1586 }
1587 }
1588 }
1589 return null;
1590 }
1591
1592 /**
1593 * Adds an error to the verified error list
1594 *
1595 * @since 1.14.2
1596 *
1597 * @param array $error The error array, as it was saved in the unverified errors list.
1598 * @return void
1599 */
1600 public function verify_error( $error ) {
1601
1602 $verified_errors = $this->get_verified_errors();
1603 $error_code = $error['error_code'];
1604 $user_id = $error['user_id'];
1605
1606 if ( ! isset( $verified_errors[ $error_code ] ) ) {
1607 $verified_errors[ $error_code ] = array();
1608 }
1609
1610 $verified_errors[ $error_code ][ $user_id ] = $error;
1611
1612 update_option( self::STORED_VERIFIED_ERRORS_OPTION, $verified_errors );
1613
1614 // Invalidate cache since we added a new verified error
1615 $this->invalidate_displayable_errors_cache();
1616 }
1617
1618 /**
1619 * Register REST API end point for error handling.
1620 *
1621 * @since 1.14.2
1622 *
1623 * @return void
1624 */
1625 public function register_verify_error_endpoint() {
1626 register_rest_route(
1627 'jetpack/v4',
1628 '/verify_xmlrpc_error',
1629 array(
1630 'methods' => \WP_REST_Server::CREATABLE,
1631 'callback' => array( $this, 'verify_xml_rpc_error' ),
1632 'permission_callback' => '__return_true',
1633 'args' => array(
1634 'nonce' => array(
1635 'required' => true,
1636 'type' => 'string',
1637 ),
1638 ),
1639 )
1640 );
1641 }
1642
1643 /**
1644 * Handles verification that a xml rpc error is legit and came from WordPres.com
1645 *
1646 * @since 1.14.2
1647 *
1648 * @param \WP_REST_Request $request The request sent to the WP REST API.
1649 *
1650 * @return boolean
1651 */
1652 public function verify_xml_rpc_error( \WP_REST_Request $request ) {
1653 $error = $this->get_error_by_nonce( $request['nonce'] );
1654
1655 if ( $error ) {
1656 $this->verify_error( $error );
1657 return new \WP_REST_Response( true, 200 );
1658 }
1659
1660 return new \WP_REST_Response( false, 200 );
1661 }
1662
1663 /**
1664 * Prints a generic error notice for all connection errors
1665 *
1666 * @since 8.9.0
1667 *
1668 * @return void
1669 */
1670 public function generic_admin_notice_error() {
1671 // do not add admin notice to the jetpack dashboard.
1672 global $pagenow;
1673 if ( 'admin.php' === $pagenow || isset( $_GET['page'] ) && 'jetpack' === $_GET['page'] ) { // phpcs:ignore
1674 return;
1675 }
1676
1677 if ( ! current_user_can( 'jetpack_connect' ) ) {
1678 return;
1679 }
1680
1681 $displayable_errors = $this->get_displayable_errors();
1682
1683 // Most errors default to no admin notice — consumers opt in via the filter
1684 // below, and the React dashboard is the primary surface. Error codes whose
1685 // display config sets `default_admin_notice` provide their own message and
1686 // do not depend on a consumer supplying one.
1687 $default_message = '';
1688 $notice_link = null;
1689 foreach ( $displayable_errors as $error_code => $user_errors ) {
1690 $display_config = $this->get_error_display_config( $error_code );
1691 if ( empty( $display_config['default_admin_notice'] ) ) {
1692 continue;
1693 }
1694 // On selected hosting platforms the displayable errors pass through a
1695 // consumer filter, so the shape is not guaranteed.
1696 if ( ! is_array( $user_errors ) ) {
1697 continue;
1698 }
1699 $first_error = reset( $user_errors );
1700 if ( is_array( $first_error ) && ! empty( $first_error['error_message'] ) ) {
1701 $default_message = $first_error['error_message'];
1702 $notice_link = $display_config['notice_link'] ?? null;
1703 break;
1704 }
1705 }
1706
1707 /**
1708 * Filters the message to be displayed in the admin notices area when there's a connection error.
1709 *
1710 * By default we don't display any errors, except for the blocked-request error
1711 * (`xmlrpc_request_blocked`), which provides its own default message.
1712 *
1713 * Return an empty value to disable the message.
1714 *
1715 * @since 8.9.0
1716 * @since 8.10.0 The default message is no longer always empty.
1717 *
1718 * @param string $message The error message.
1719 * @param array $errors The array of errors. See Automattic\Jetpack\Connection\Error_Handler for details on the array structure.
1720 */
1721 $message = apply_filters( 'jetpack_connection_error_notice_message', $default_message, $displayable_errors );
1722
1723 /**
1724 * Fires inside the admin_notices hook just before displaying the error message for a broken connection.
1725 *
1726 * If you want to disable the default message from being displayed, return an empty value in the jetpack_connection_error_notice_message filter.
1727 *
1728 * @since 8.9.0
1729 *
1730 * @param array $errors The array of errors. See Automattic\Jetpack\Connection\Error_Handler for details on the array structure.
1731 */
1732 do_action( 'jetpack_connection_error_notice', $displayable_errors );
1733
1734 if ( empty( $message ) ) {
1735 return;
1736 }
1737
1738 $notice_content = esc_html( $message );
1739
1740 // Append the link only when the notice is showing the unmodified default
1741 // message — a filtered message keeps full control of the copy.
1742 if ( $notice_link && $message === $default_message && ! empty( $notice_link['url'] ) && ! empty( $notice_link['label'] ) ) {
1743 $notice_content .= sprintf(
1744 ' <a href="%1$s">%2$s</a>',
1745 esc_url( $notice_link['url'] ),
1746 esc_html( $notice_link['label'] )
1747 );
1748 }
1749
1750 wp_admin_notice(
1751 $notice_content,
1752 array(
1753 'type' => 'error',
1754 'dismissible' => true,
1755 'additional_classes' => array( 'jetpack-message', 'jp-connect' ),
1756 'attributes' => array( 'style' => 'display:block !important;' ),
1757 )
1758 );
1759 }
1760
1761 /**
1762 * Check an outgoing signed request's response for errors, and store them if needed.
1763 *
1764 * This is the entry point of the outgoing-request error flow (flow 2 in the class
1765 * docblock). `Client::remote_request()` calls it after every outgoing signed request.
1766 * Errors captured here are stored directly as verified — the WP.com verification
1767 * round-trip used for incoming errors is unnecessary, because the error arrived in a
1768 * response to a request this site itself initiated and signed.
1769 *
1770 * Note: XML-RPC faults arrive as HTTP 200 responses with an XML body, so they are
1771 * invisible to this method — only errors surfaced at the HTTP level with a JSON error
1772 * envelope are captured. `Jetpack_IXR_Client::query()` reports faults itself, via
1773 * check_xmlrpc_fault_for_errors().
1774 *
1775 * @see wp_remote_request() For more information on the $http_response array format.
1776 * @param array|\WP_Error $http_response The response or WP_Error on failure.
1777 * @param array $auth_data Auth data, allowed keys: `token`, `timestamp`, `nonce`, `body-hash`.
1778 * @param string $url Request URL.
1779 * @param string $method Request method.
1780 * @param string $error_type The transport of the outgoing request: `ERROR_TYPE_XMLRPC` or `ERROR_TYPE_REST`.
1781 *
1782 * @return void
1783 */
1784 public function check_api_response_for_errors( $http_response, $auth_data, $url, $method, $error_type ) {
1785 if ( 200 === wp_remote_retrieve_response_code( $http_response ) || ! is_array( $auth_data ) || ! $url || ! $method ) {
1786 return;
1787 }
1788
1789 $body_raw = wp_remote_retrieve_body( $http_response );
1790 if ( ! $body_raw ) {
1791 return;
1792 }
1793
1794 $body = json_decode( $body_raw, true );
1795
1796 // Support both error envelopes: the legacy v1 JSON-API shape (`error`) and the
1797 // WP-API v2 shape (`code`), the latter used by `wpcom/v2` endpoints such as
1798 // `jetpack-wpcom-user-data`. Prefer `error` for backwards compatibility.
1799 $error_code = is_array( $body ) ? ( $body['error'] ?? $body['code'] ?? null ) : null;
1800
1801 if ( empty( $error_code ) || ( ! is_string( $error_code ) && ! is_int( $error_code ) ) ) {
1802 return;
1803 }
1804
1805 $error = self::build_connection_wp_error(
1806 (string) $error_code,
1807 empty( $body['message'] ) ? '' : $body['message'],
1808 array(
1809 'token' => empty( $auth_data['token'] ) ? '' : $auth_data['token'],
1810 'timestamp' => empty( $auth_data['timestamp'] ) ? '' : $auth_data['timestamp'],
1811 'nonce' => empty( $auth_data['nonce'] ) ? '' : $auth_data['nonce'],
1812 // `Client::build_signed_request()` builds this key as `body-hash` (it is sent as an
1813 // `Authorization` header parameter). The snake_case fallback keeps callers that pass
1814 // the stored `signature_details` shape working.
1815 'body_hash' => $auth_data['body-hash'] ?? $auth_data['body_hash'] ?? '',
1816 'method' => $method,
1817 'url' => $url,
1818 ),
1819 $error_type,
1820 self::DIRECTION_OUTGOING
1821 );
1822
1823 $this->report_error( $error, false, true );
1824 }
1825
1826 /**
1827 * Check the result of signing an outgoing request for errors, and store them if needed.
1828 *
1829 * This is the second entry point of the outgoing-request error flow (flow 2 in the class
1830 * docblock). It handles failures from `Client::build_signed_request()`, which
1831 * occur before a request is sent and therefore have no response to inspect.
1832 *
1833 * Like response errors in flow 2, these are stored as verified without a WP.com
1834 * round-trip because the site's own token and URL state provides the evidence.
1835 * The hourly reporting gate in `report_error()` still applies.
1836 *
1837 * Codes reaching this method include `malformed_token` and `invalid_body` (from
1838 * `Client::build_signed_request()`), plus the signing errors returned by
1839 * `Jetpack_Signature::sign_request()` (e.g. `invalid_scheme`, `unknown_scheme_port`),
1840 * plus the token-lookup errors raised by `Tokens::get_access_token()` (e.g.
1841 * `no_user_tokens`, `no_token_for_user`). `tokens_locked` also reaches here but is not
1842 * in `known_errors`, so `report_error()` silently discards it — see the comment on
1843 * `Client::build_signed_request()`'s `tokens_locked` branch for why.
1844 *
1845 * This includes token lookup, request validation, and request signing errors.
1846 *
1847 * @since 8.10.1
1848 *
1849 * @param mixed $signing_result The return value of `Client::build_signed_request()`. Ignored unless it is a `WP_Error`.
1850 * @param string $url Request URL.
1851 * @param string $method Request method.
1852 * @param string $error_type The transport of the outgoing request: `ERROR_TYPE_XMLRPC` or `ERROR_TYPE_REST`.
1853 *
1854 * @return void
1855 */
1856 public function check_signed_request_for_errors( $signing_result, $url, $method, $error_type ) {
1857 if ( ! is_wp_error( $signing_result ) ) {
1858 return;
1859 }
1860
1861 $data = $signing_result->get_error_data();
1862
1863 // The signing errors raised by `Jetpack_Signature` already carry the details of the
1864 // request they failed to sign; the ones raised by `Client` itself carry nothing.
1865 $signature_details = isset( $data['signature_details'] ) && is_array( $data['signature_details'] )
1866 ? $data['signature_details']
1867 : array();
1868
1869 $signature_details += array(
1870 'method' => $method,
1871 'url' => $url,
1872 );
1873
1874 $error = self::build_connection_wp_error(
1875 (string) $signing_result->get_error_code(),
1876 $signing_result->get_error_message(),
1877 $signature_details,
1878 $error_type,
1879 self::DIRECTION_OUTGOING,
1880 // `Tokens::get_access_token()` attaches `user_id` to the WP_Errors it raises when it
1881 // has already resolved one (see its docblock); pass it through as the attribution
1882 // fallback consulted by `wp_error_to_array()`. Errors with no token to look up at all
1883 // (e.g. `tokens_locked`, `malformed_token` from `Client` itself) carry no such data,
1884 // and fall back to unattributed there.
1885 array( 'user_id' => isset( $data['user_id'] ) ? (int) $data['user_id'] : 0 )
1886 );
1887
1888 $this->report_error( $error, false, true );
1889 }
1890
1891 /**
1892 * Check an outgoing XML-RPC request's fault response for errors, and store them if needed.
1893 *
1894 * This is the third entry point of the outgoing-request error flow (flow 2 in the class
1895 * docblock). XML-RPC faults arrive as HTTP 200 responses with an XML body, so
1896 * check_api_response_for_errors() never sees them — it returns immediately on a 200,
1897 * and decodes the body as JSON rather than XML anyway. `Jetpack_IXR_Client::query()`
1898 * calls this method directly from its fault branch instead.
1899 *
1900 * The code/message pair is recovered from the fault string by the caller, via
1901 * `Jetpack_IXR_Client::parse_jetpack_fault_string()` — the class that owns the
1902 * `Jetpack: [code] message` convention. An unparseable fault string is the caller's
1903 * concern, not this method's; a fault code reaching here is untrusted input, and it's
1904 * `report_error()`'s `$known_errors` allowlist, not this method, that keeps an
1905 * unrecognized code from being stored. In practice every `jetpack.*` XML-RPC handler
1906 * on WP.com emits fixed string literals here, never attacker- or request-composed
1907 * ones, and the handful that are also `$known_errors` (`unknown_token`,
1908 * `signature_mismatch`, `invalid_token`, `token_mismatch`, `invalid_signature`) are
1909 * the same codes this site itself raises for the same failure — WP.com is just
1910 * verifying signatures with the same scheme.
1911 *
1912 * @since 8.10.4
1913 *
1914 * @param string $error_code The Jetpack error code parsed from the fault string.
1915 * @param string $error_message The error message parsed from the fault string.
1916 * @param string $url Request URL.
1917 * @param string $method Request method.
1918 * @param int $user_id The local user ID the request was signed for, or `0` for the blog token.
1919 *
1920 * @return void
1921 */
1922 public function check_xmlrpc_fault_for_errors( string $error_code, string $error_message, string $url, string $method, int $user_id = 0 ) {
1923 $error = self::build_connection_wp_error(
1924 $error_code,
1925 $error_message,
1926 array(
1927 'method' => $method,
1928 'url' => $url,
1929 ),
1930 self::ERROR_TYPE_XMLRPC,
1931 self::DIRECTION_OUTGOING,
1932 array( 'user_id' => $user_id )
1933 );
1934
1935 $this->report_error( $error, false, true );
1936 }
1937
1938 /**
1939 * Determines whether external filters are applied to the get_displayable_errors method.
1940 *
1941 * @since 6.13.10
1942 *
1943 * @return bool True if external filters are applied, false otherwise.
1944 */
1945 private function has_external_filters() {
1946 return has_filter( 'jetpack_connection_get_verified_errors' ) &&
1947 $this->should_allow_error_filtering();
1948 }
1949
1950 /**
1951 * Invalidates the cached displayable errors
1952 *
1953 * @since 6.13.10
1954 *
1955 * @return void
1956 */
1957 private function invalidate_displayable_errors_cache() {
1958 $this->cached_displayable_errors = null;
1959 }
1960 }
1961