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 |