← All changes
|
jetpack_vendor/automattic/jetpack-connection/src/class-error-handler.php
+220
-47
16.2
→
16.3-a.7
View file →
| @@ -216,8 +216,9 @@ | ||
| 216 | 216 | 'signature_mismatch', // Computed signature differs: wrong secret, or URL/body drift (domain change, proxy). |
| 217 | 217 | // Connection state problems (Manager::get_connection_owner, Connection_Health_Tests). |
| 218 | 218 | 'invalid_connection_owner', // The connection owner cannot be resolved: token missing or WP user deleted. |
| 219 | 219 | 'xmlrpc_request_blocked', // WP.com reached the site but the request was rejected (firewall, WAF, or server rule). |
| 220 | + 'wpcom_ssl_verification_failed', // WP.com could not verify the site's SSL certificate when connecting to it (expired, self-signed, or incomplete chain). | |
| 220 | 221 | ); |
| 221 | 222 | |
| 222 | 223 | /** |
| 223 | 224 | * Holds the instance of this singleton class |
| @@ -266,13 +267,19 @@ | ||
| 266 | 267 | * that can be safely displayed in My Jetpack and other UI components. It includes |
| 267 | 268 | * predefined error messages and actions, with optional filtering for specific sites. |
| 268 | 269 | * Only processes a limited set of error codes that are meant to be displayed to users. |
| 269 | 270 | * |
| 271 | + * The result is specific to the current viewer: an error is omitted entirely when | |
| 272 | + * they lack the capability to resolve it, so viewer-facing surfaces need not gate | |
| 273 | + * it again. Two exceptions: a context with no current user gets the unfiltered set, | |
| 274 | + * and consumer-injected errors are appended after the gate. See docs/error-handling.md. | |
| 275 | + * | |
| 270 | 276 | * error_data.action is only set when it deviates from the default behavior |
| 271 | 277 | * (e.g. 'none' to suppress the reconnect CTA); when absent, readers fall back |
| 272 | 278 | * to offering the reconnect CTA. |
| 273 | 279 | * |
| 274 | 280 | * @since 6.13.10 |
| 281 | + * @since 9.8.0 Withholds a non-admin's reconnect CTA while a site connection error is on record. | |
| 275 | 282 | * |
| 276 | 283 | * @return array Array of displayable errors with hierarchical structure. |
| 277 | 284 | * Example: |
| 278 | 285 | * [ |
| @@ -314,8 +321,13 @@ | ||
| 314 | 321 | $owner_id = (int) \Jetpack_Options::get_option( 'master_user' ); |
| 315 | 322 | $viewer_is_owner = $owner_id > 0 && $viewer_id === $owner_id; |
| 316 | 323 | $is_transferable = ( new Manager() )->is_ownership_transferable(); |
| 317 | 324 | |
| 325 | + // Viewer-wide, so resolved once rather than per error. | |
| 326 | + $viewer_can_connect = current_user_can( 'jetpack_connect' ); | |
| 327 | + $viewer_can_connect_user = current_user_can( 'jetpack_connect_user' ); | |
| 328 | + $site_connection_broken = ! $viewer_can_connect && $this->has_site_connection_error( $verified_errors, $owner_id ); | |
| 329 | + | |
| 318 | 330 | foreach ( $verified_errors as $error_code => $users ) { |
| 319 | 331 | // Only process error codes that are meant to be displayed to users. |
| 320 | 332 | // A raw verified error whose code is marked non-displayable in |
| 321 | 333 | // get_error_display_configs() is never surfaced. |
| @@ -352,8 +364,19 @@ | ||
| 352 | 364 | && 'invalid_connection_owner' !== $error_code ) { |
| 353 | 365 | continue; |
| 354 | 366 | } |
| 355 | 367 | |
| 368 | + // An error a viewer cannot act on is withheld entirely. | |
| 369 | + $viewer_owns_error = 'user' === $audience && ! $this->is_owner_scoped_error( $error_code, $audience ); | |
| 370 | + | |
| 371 | + if ( $viewer_id > 0 ) { | |
| 372 | + $can_view_error = $viewer_owns_error ? $viewer_can_connect_user : $viewer_can_connect; | |
| 373 | + | |
| 374 | + if ( ! $can_view_error ) { | |
| 375 | + continue; | |
| 376 | + } | |
| 377 | + } | |
| 378 | + | |
| 356 | 379 | $message = $generic_message; |
| 357 | 380 | $action = null; |
| 358 | 381 | |
| 359 | 382 | if ( isset( $display_config['message_callback'] ) ) { |
| @@ -374,10 +397,9 @@ | ||
| 374 | 397 | // A secondary admin looking at the connection owner's token error. What |
| 375 | 398 | // they can usefully be told depends on whether ownership is transferable. |
| 376 | 399 | // Only name the owner, or describe what reconnecting would do, for |
| 377 | 400 | // viewers who can act on connection issues. |
| 378 | - $viewer_can_connect = current_user_can( 'jetpack_connect' ); | |
| 379 | - $owner_name = ''; | |
| 401 | + $owner_name = ''; | |
| 380 | 402 | if ( $viewer_can_connect ) { |
| 381 | 403 | $owner = get_userdata( $owner_id ); |
| 382 | 404 | $owner_name = $owner instanceof \WP_User ? $owner->display_name : ''; |
| 383 | 405 | } |
| @@ -408,8 +430,15 @@ | ||
| 408 | 430 | : __( '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 | 431 | } |
| 410 | 432 | } |
| 411 | 433 | |
| 434 | + // A non-admin relinks over the site connection, so while it is broken the | |
| 435 | + // reconnect CTA cannot work for them. A reporter-declared action is left alone. | |
| 436 | + if ( $viewer_owns_error && $site_connection_broken && empty( $error['error_data']['action'] ) ) { | |
| 437 | + $message = __( 'Your WordPress.com account connection is broken, and the site connection needs attention too. Ask an administrator to restore the site connection, then reconnect your account.', 'jetpack-connection' ); | |
| 438 | + $action = 'none'; | |
| 439 | + } | |
| 440 | + | |
| 412 | 441 | $error['audience'] = $audience; |
| 413 | 442 | $error['error_message'] = $message; |
| 414 | 443 | |
| 415 | 444 | // Only emit error_data.action when it deviates from the default. Readers |
| @@ -415,9 +444,12 @@ | ||
| 415 | 444 | // Only emit error_data.action when it deviates from the default. Readers |
| 416 | 445 | // already fall back to the reconnect CTA when no action is set, and |
| 417 | 446 | // injecting an explicit 'reconnect' could trip consumer code paths |
| 418 | 447 | // reserved for custom actions. |
| 419 | - if ( null !== $action || ! empty( $display_config['support_link'] ) ) { | |
| 448 | + $notice_link = $display_config['notice_link'] ?? null; | |
| 449 | + $has_link = ! empty( $notice_link['url'] ) && ! empty( $notice_link['label'] ); | |
| 450 | + | |
| 451 | + if ( null !== $action || ! empty( $display_config['support_link'] ) || $has_link ) { | |
| 420 | 452 | $error_data = ( isset( $error['error_data'] ) && is_array( $error['error_data'] ) ) ? $error['error_data'] : array(); |
| 421 | 453 | |
| 422 | 454 | if ( null !== $action ) { |
| 423 | 455 | $error_data['action'] = $action; |
| @@ -429,8 +461,21 @@ | ||
| 429 | 461 | if ( ! empty( $display_config['support_link'] ) ) { |
| 430 | 462 | $error_data['support_link'] = true; |
| 431 | 463 | } |
| 432 | 464 | |
| 465 | + // Where the resolution lives somewhere else (Site Health for a | |
| 466 | + // blocked request), carry the link on the error so every notice can | |
| 467 | + // offer it — not just the wp-admin one. Errors like this suppress | |
| 468 | + // the reconnect CTA, so without it the notice names a problem and | |
| 469 | + // offers nothing to do about it. See `notice_link` in | |
| 470 | + // get_error_display_configs(). | |
| 471 | + if ( $has_link ) { | |
| 472 | + $error_data['notice_link'] = array( | |
| 473 | + 'label' => $notice_link['label'], | |
| 474 | + 'url' => $notice_link['url'], | |
| 475 | + ); | |
| 476 | + } | |
| 477 | + | |
| 433 | 478 | $error['error_data'] = $error_data; |
| 434 | 479 | } |
| 435 | 480 | |
| 436 | 481 | if ( ! isset( $displayable_errors[ $error_code ] ) ) { |
| @@ -507,11 +552,22 @@ | ||
| 507 | 552 | * `jetpack_connection_error_notice_message` filter (which still overrides). |
| 508 | 553 | * This is the only key that reaches beyond My Jetpack's own display: it opts |
| 509 | 554 | * the code into a site-wide wp-admin notice. Leave it unset unless the error |
| 510 | 555 | * 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). | |
| 556 | + * - `notice_link` (array): presentational `label` and `url` for a link the notice | |
| 557 | + * offers alongside (or instead of) the CTA. It reaches two surfaces, gated | |
| 558 | + * differently on purpose: | |
| 559 | + * - The default wp-admin notice appends it only when showing this error's own | |
| 560 | + * default message. There, `jetpack_connection_error_notice_message` hands the | |
| 561 | + * consumer a bare string with no way to drop the link, so a filtered message | |
| 562 | + * that kept it could end up pointing somewhere its copy never mentions. | |
| 563 | + * - The displayable error carries it as `error_data['notice_link']` | |
| 564 | + * unconditionally, for the connection JS package to render in its own notices. | |
| 565 | + * No equivalent gate is possible or needed: `error_message` on this path is | |
| 566 | + * not filtered through anything, and the one filter that can rewrite it — | |
| 567 | + * `jetpack_connection_displayable_errors` below — receives the whole error | |
| 568 | + * array, link included, so a consumer changing the copy can unset the link in | |
| 569 | + * the same pass. | |
| 514 | 570 | * - `survives_owner_promotion` (bool): when true, this code is not dropped by |
| 515 | 571 | * promote_owner_errors() while the connection owner's own connection is broken. |
| 516 | 572 | * Set it only for a code that is not a token problem, and so is not waiting on |
| 517 | 573 | * the owner's reconnect to become actionable. Setting it does not make the code |
| @@ -562,61 +618,61 @@ | ||
| 562 | 618 | // what the generic copy already says. |
| 563 | 619 | $configs = array( |
| 564 | 620 | // Attacker-controllable garbage in an incoming request. Nothing about this |
| 565 | 621 | // site's own connection is wrong. |
| 566 | - 'malformed_user_id' => false, | |
| 622 | + 'malformed_user_id' => false, | |
| 567 | 623 | // Expected after a user is deleted, and the owner flavor is covered by |
| 568 | 624 | // invalid_connection_owner. Incoming reports also drive WP.com-side |
| 569 | 625 | // self-healing, so a notice would surface a problem already resolving itself. |
| 570 | - 'unknown_user' => false, | |
| 571 | - 'malformed_token' => array(), | |
| 626 | + 'unknown_user' => false, | |
| 627 | + 'malformed_token' => array(), | |
| 572 | 628 | // Never connecting a WordPress.com account is expected, not broken. The owner |
| 573 | 629 | // flavor is covered by invalid_connection_owner. |
| 574 | - 'no_user_tokens' => false, | |
| 630 | + 'no_user_tokens' => false, | |
| 575 | 631 | // Same, for a site that has never had an owner. invalid_connection_owner |
| 576 | 632 | // covers the case where there was one and it broke. |
| 577 | - 'empty_master_user_option' => false, | |
| 633 | + 'empty_master_user_option' => false, | |
| 578 | 634 | // As no_user_tokens, for a single requested user. |
| 579 | - 'no_token_for_user' => false, | |
| 580 | - 'token_malformed' => array(), | |
| 635 | + 'no_token_for_user' => false, | |
| 636 | + 'token_malformed' => array(), | |
| 581 | 637 | // Corrupt local token data, but for one user only, and the |
| 582 | 638 | // no_valid_user_token/token_malformed pair surfaces it when it actually |
| 583 | 639 | // 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(), | |
| 640 | + 'user_id_mismatch' => false, | |
| 641 | + 'no_possible_tokens' => array(), | |
| 642 | + 'no_valid_user_token' => array(), | |
| 643 | + 'no_valid_blog_token' => array(), | |
| 644 | + 'unknown_token' => array(), | |
| 645 | + 'could_not_sign' => array(), | |
| 590 | 646 | // Both are about the URL being signed, not the connection: a code bug or an |
| 591 | 647 | // exotic site URL, which reconnecting does not change. |
| 592 | - 'invalid_scheme' => false, | |
| 593 | - 'unknown_scheme_port' => false, | |
| 648 | + 'invalid_scheme' => false, | |
| 649 | + 'unknown_scheme_port' => false, | |
| 594 | 650 | // Corrupt local token data like token_malformed above, caught at signing time |
| 595 | 651 | // rather than lookup time. Reconnect fixes it the same way. |
| 596 | - 'invalid_secret' => array(), | |
| 597 | - 'invalid_token' => array(), | |
| 598 | - 'token_mismatch' => array(), | |
| 652 | + 'invalid_secret' => array(), | |
| 653 | + 'invalid_token' => array(), | |
| 654 | + 'token_mismatch' => array(), | |
| 599 | 655 | // Per-request and transport-level, so unaffected by the state of the connection. |
| 600 | - 'invalid_body' => false, | |
| 656 | + 'invalid_body' => false, | |
| 601 | 657 | // Environmental in both directions — a malformed parameter or clock skew, |
| 602 | 658 | // neither of which a reconnect fixes. |
| 603 | - 'invalid_signature' => false, | |
| 659 | + 'invalid_signature' => false, | |
| 604 | 660 | // Something altered the request in transit. Not a token problem, and |
| 605 | 661 | // signature_mismatch carries the same diagnosis with usable copy. |
| 606 | - 'invalid_body_hash' => false, | |
| 662 | + 'invalid_body_hash' => false, | |
| 607 | 663 | // A replay, or object-cache trouble. Self-resolving per request. |
| 608 | - 'invalid_nonce' => false, | |
| 664 | + 'invalid_nonce' => false, | |
| 609 | 665 | // Ambiguous cause: could be a genuine secret desync (reconnect fixes it) or a |
| 610 | 666 | // proxy/CDN/WAF/security plugin altering the request in transit (reconnect |
| 611 | 667 | // doesn't help). Uses the generic message — support_link offers an |
| 612 | 668 | // alternative either way. |
| 613 | - 'signature_mismatch' => array( | |
| 669 | + 'signature_mismatch' => array( | |
| 614 | 670 | 'support_link' => true, |
| 615 | 671 | ), |
| 616 | 672 | // Two flavors with different remedies — see |
| 617 | 673 | // get_invalid_connection_owner_message(). |
| 618 | - 'invalid_connection_owner' => array( | |
| 674 | + 'invalid_connection_owner' => array( | |
| 619 | 675 | 'message_callback' => array( $this, 'get_invalid_connection_owner_message' ), |
| 620 | 676 | ), |
| 621 | 677 | // The token can be perfectly valid here: the site is rejecting WordPress.com's |
| 622 | 678 | // requests, so a reconnect would be rejected the same way. The callback |
| @@ -623,9 +679,9 @@ | ||
| 623 | 679 | // suppresses the reconnect CTA and names the real cause, staying brief because |
| 624 | 680 | // Site Health holds the full diagnosis. Ships a default admin notice because |
| 625 | 681 | // no other detection path can see this — WP.com's requests never arrive. And |
| 626 | 682 | // it outlives a broken owner, whose reconnect the same rule would block. |
| 627 | - 'xmlrpc_request_blocked' => array( | |
| 683 | + 'xmlrpc_request_blocked' => array( | |
| 628 | 684 | 'message_callback' => array( $this, 'get_blocked_request_message' ), |
| 629 | 685 | 'default_admin_notice' => true, |
| 630 | 686 | 'survives_owner_promotion' => true, |
| 631 | 687 | 'notice_link' => array( |
| @@ -632,8 +688,22 @@ | ||
| 632 | 688 | 'label' => __( 'Visit Site Health', 'jetpack-connection' ), |
| 633 | 689 | 'url' => admin_url( 'site-health.php' ), |
| 634 | 690 | ), |
| 635 | 691 | ), |
| 692 | + // The tokens can be perfectly valid: WP.com cannot verify the site's SSL | |
| 693 | + // certificate, and a reconnect would be rejected the same way — so no | |
| 694 | + // reconnect CTA, and it outlives a broken owner. Ships a default admin notice | |
| 695 | + // for the same reason as the blocked error above: WP.com's requests never | |
| 696 | + // arrive, so no other detection path can see this. | |
| 697 | + 'wpcom_ssl_verification_failed' => array( | |
| 698 | + 'message_callback' => array( $this, 'get_wpcom_ssl_verification_failed_message' ), | |
| 699 | + 'default_admin_notice' => true, | |
| 700 | + 'survives_owner_promotion' => true, | |
| 701 | + 'notice_link' => array( | |
| 702 | + 'label' => __( 'Visit Site Health', 'jetpack-connection' ), | |
| 703 | + 'url' => admin_url( 'site-health.php' ), | |
| 704 | + ), | |
| 705 | + ), | |
| 636 | 706 | ); |
| 637 | 707 | |
| 638 | 708 | return $configs; |
| 639 | 709 | } |
| @@ -679,8 +749,23 @@ | ||
| 679 | 749 | 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 | 750 | } |
| 681 | 751 | |
| 682 | 752 | /** |
| 753 | + * Builds the displayable message for the SSL-verification-failed error. | |
| 754 | + * | |
| 755 | + * Deliberately brief: Site Health holds the transport detail and the resolution | |
| 756 | + * steps, so the message only names the condition and points there. | |
| 757 | + * | |
| 758 | + * @since 9.3.0 | |
| 759 | + * | |
| 760 | + * @param array $error The stored error array (unused; part of the message_callback contract). | |
| 761 | + * @return string The message. | |
| 762 | + */ | |
| 763 | + private function get_wpcom_ssl_verification_failed_message( $error ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable | |
| 764 | + return __( 'WordPress.com cannot securely connect to your site because its SSL certificate could not be verified. See Site Health for details and next steps.', 'jetpack-connection' ); | |
| 765 | + } | |
| 766 | + | |
| 767 | + /** | |
| 683 | 768 | * Classifies the audience of a stored connection error based on its user ID. |
| 684 | 769 | * |
| 685 | 770 | * The audience determines who a connection error is relevant to and, in turn, |
| 686 | 771 | * how it should be surfaced: |
| @@ -711,8 +796,56 @@ | ||
| 711 | 796 | return 'user'; |
| 712 | 797 | } |
| 713 | 798 | |
| 714 | 799 | /** |
| 800 | + * Whether a displayable site-audience error is on record that would stop a user relinking. | |
| 801 | + * | |
| 802 | + * Codes that survive owner promotion are inbound failures (WordPress.com cannot reach or | |
| 803 | + * verify the site), which leave the outbound, blog-token-signed relink working. | |
| 804 | + * | |
| 805 | + * @since 9.8.0 | |
| 806 | + * | |
| 807 | + * @param array $verified_errors The verified errors, keyed by error code then user ID. | |
| 808 | + * @param int $owner_id The local user ID of the connection owner, or 0 if there is none. | |
| 809 | + * @return bool | |
| 810 | + */ | |
| 811 | + private function has_site_connection_error( $verified_errors, $owner_id ) { | |
| 812 | + foreach ( $verified_errors as $error_code => $users ) { | |
| 813 | + $display_config = $this->get_error_display_config( $error_code ); | |
| 814 | + | |
| 815 | + if ( null === $display_config || ! empty( $display_config['survives_owner_promotion'] ) || ! is_array( $users ) ) { | |
| 816 | + continue; | |
| 817 | + } | |
| 818 | + | |
| 819 | + foreach ( array_keys( $users ) as $user_id ) { | |
| 820 | + // Must precede classification, which would cast 'invalid' to 0 and read it as 'site'. | |
| 821 | + if ( 'invalid' === $user_id ) { | |
| 822 | + continue; | |
| 823 | + } | |
| 824 | + | |
| 825 | + if ( 'site' === $this->classify_error_audience( $user_id, $owner_id ) ) { | |
| 826 | + return true; | |
| 827 | + } | |
| 828 | + } | |
| 829 | + } | |
| 830 | + | |
| 831 | + return false; | |
| 832 | + } | |
| 833 | + | |
| 834 | + /** | |
| 835 | + * Whether an error describes the connection owner's own connection. | |
| 836 | + * | |
| 837 | + * @since 9.2.0 | |
| 838 | + * | |
| 839 | + * @param string $error_code The error code. | |
| 840 | + * @param string $audience The classified audience. | |
| 841 | + * @return bool | |
| 842 | + */ | |
| 843 | + private function is_owner_scoped_error( $error_code, $audience ) { | |
| 844 | + return 'owner' === $audience || 'invalid_connection_owner' === $error_code; | |
| 845 | + } | |
| 846 | + | |
| 847 | + /** | |
| 715 | 848 | * Reduces a set of displayable errors to the connection-owner ones when the |
| 716 | 849 | * owner's own connection is broken. |
| 717 | 850 | * |
| 718 | 851 | * The connection owner is the account every other connection on the site hangs |
| @@ -718,14 +851,9 @@ | ||
| 718 | 851 | * The connection owner is the account every other connection on the site hangs |
| 719 | 852 | * off. While it is broken, no other error in the set is independently |
| 720 | 853 | * actionable. |
| 721 | 854 | * |
| 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. | |
| 855 | + * See is_owner_scoped_error() for which errors count as a broken owner. | |
| 728 | 856 | * |
| 729 | 857 | * A code whose display config sets `survives_owner_promotion` is kept regardless. |
| 730 | 858 | * The premise above holds for token errors, whose one remedy is a reconnect the |
| 731 | 859 | * owner has to perform first — see that key's documentation on |
| @@ -747,10 +875,9 @@ | ||
| 747 | 875 | $display_config = $this->get_error_display_config( $error_code ); |
| 748 | 876 | $survives = null !== $display_config && ! empty( $display_config['survives_owner_promotion'] ); |
| 749 | 877 | |
| 750 | 878 | foreach ( $users as $user_id => $error ) { |
| 751 | - $is_owner_error = 'owner' === ( $error['audience'] ?? '' ) | |
| 752 | - || 'invalid_connection_owner' === $error_code; | |
| 879 | + $is_owner_error = $this->is_owner_scoped_error( $error_code, $error['audience'] ?? '' ); | |
| 753 | 880 | |
| 754 | 881 | if ( ! $is_owner_error && ! $survives ) { |
| 755 | 882 | continue; |
| 756 | 883 | } |
| @@ -1025,9 +1152,11 @@ | ||
| 1025 | 1152 | $keys = array_keys( $stored_errors[ $error_code ] ); |
| 1026 | 1153 | unset( $stored_errors[ $error_code ][ $keys[0] ] ); |
| 1027 | 1154 | } |
| 1028 | 1155 | |
| 1029 | - if ( update_option( self::STORED_ERRORS_OPTION, $stored_errors ) ) { | |
| 1156 | + // Deliberately not autoloaded: keeps these ephemeral options out of the shared | |
| 1157 | + // alloptions cache blob, whose write races can resurrect deleted values (CONNECT-457). | |
| 1158 | + if ( update_option( self::STORED_ERRORS_OPTION, $stored_errors, false ) ) { | |
| 1030 | 1159 | return $error_array; |
| 1031 | 1160 | } |
| 1032 | 1161 | |
| 1033 | 1162 | return false; |
| @@ -1478,9 +1607,9 @@ | ||
| 1478 | 1607 | $stored_errors = $this->get_stored_errors(); |
| 1479 | 1608 | if ( is_array( $stored_errors ) && count( $stored_errors ) ) { |
| 1480 | 1609 | $stored_errors = array_filter( array_map( $type_filter, $stored_errors ) ); |
| 1481 | 1610 | if ( count( $stored_errors ) ) { |
| 1482 | - update_option( static::STORED_ERRORS_OPTION, $stored_errors ); | |
| 1611 | + update_option( static::STORED_ERRORS_OPTION, $stored_errors, false ); | |
| 1483 | 1612 | } else { |
| 1484 | 1613 | delete_option( static::STORED_ERRORS_OPTION ); |
| 1485 | 1614 | } |
| 1486 | 1615 | } |
| @@ -1488,14 +1617,19 @@ | ||
| 1488 | 1617 | $verified_errors = $this->get_verified_errors(); |
| 1489 | 1618 | if ( is_array( $verified_errors ) && count( $verified_errors ) ) { |
| 1490 | 1619 | $verified_errors = array_filter( array_map( $type_filter, $verified_errors ) ); |
| 1491 | 1620 | if ( count( $verified_errors ) ) { |
| 1492 | - update_option( static::STORED_VERIFIED_ERRORS_OPTION, $verified_errors ); | |
| 1621 | + update_option( static::STORED_VERIFIED_ERRORS_OPTION, $verified_errors, false ); | |
| 1493 | 1622 | } else { |
| 1494 | 1623 | delete_option( static::STORED_VERIFIED_ERRORS_OPTION ); |
| 1495 | 1624 | } |
| 1496 | 1625 | } |
| 1497 | 1626 | |
| 1627 | + // Per-key purge only (this warm path — a successful site-data fetch — must not | |
| 1628 | + // drop the alloptions blob); a legacy blob orphan clears on the next reconnect. | |
| 1629 | + wp_cache_delete( self::STORED_ERRORS_OPTION, 'options' ); | |
| 1630 | + wp_cache_delete( self::STORED_VERIFIED_ERRORS_OPTION, 'options' ); | |
| 1631 | + | |
| 1498 | 1632 | // Invalidate cache since we may have deleted verified errors |
| 1499 | 1633 | $this->invalidate_displayable_errors_cache(); |
| 1500 | 1634 | } |
| 1501 | 1635 | |
| @@ -1521,9 +1655,11 @@ | ||
| 1521 | 1655 | * |
| 1522 | 1656 | * @return boolean True, if option is successfully deleted. False on failure. |
| 1523 | 1657 | */ |
| 1524 | 1658 | public function delete_stored_errors() { |
| 1525 | - return delete_option( self::STORED_ERRORS_OPTION ); | |
| 1659 | + $deleted = delete_option( self::STORED_ERRORS_OPTION ); | |
| 1660 | + $this->purge_error_option_cache( self::STORED_ERRORS_OPTION, $deleted ); | |
| 1661 | + return $deleted; | |
| 1526 | 1662 | } |
| 1527 | 1663 | |
| 1528 | 1664 | /** |
| 1529 | 1665 | * Delete the verified errors stored in the database |
| @@ -1532,12 +1668,38 @@ | ||
| 1532 | 1668 | * |
| 1533 | 1669 | * @return boolean True, if option is successfully deleted. False on failure. |
| 1534 | 1670 | */ |
| 1535 | 1671 | public function delete_verified_errors() { |
| 1536 | - return delete_option( self::STORED_VERIFIED_ERRORS_OPTION ); | |
| 1672 | + $deleted = delete_option( self::STORED_VERIFIED_ERRORS_OPTION ); | |
| 1673 | + $this->purge_error_option_cache( self::STORED_VERIFIED_ERRORS_OPTION, $deleted ); | |
| 1674 | + return $deleted; | |
| 1537 | 1675 | } |
| 1538 | 1676 | |
| 1539 | 1677 | /** |
| 1678 | + * Purges an error option's object caches after a delete. | |
| 1679 | + * | |
| 1680 | + * Core's delete_option()/update_option() return before touching caches when the | |
| 1681 | + * DB row is missing, so a value resurrected in cache by an alloptions write race | |
| 1682 | + * would otherwise outlive the delete — including a reconnect (CONNECT-457). The | |
| 1683 | + * per-key delete covers a post-migration (non-autoloaded) orphan; when the delete | |
| 1684 | + * found no row yet the value is still in the autoloaded blob (a legacy row written | |
| 1685 | + * before these options stopped autoloading), drop that blob too. The blob check | |
| 1686 | + * reads the raw autoloaded set, so it is unaffected by option_* filters and adds | |
| 1687 | + * no query. | |
| 1688 | + * | |
| 1689 | + * @since 9.3.0 | |
| 1690 | + * | |
| 1691 | + * @param string $option The error option name. | |
| 1692 | + * @param bool $deleted Whether delete_option() found and removed a DB row. | |
| 1693 | + */ | |
| 1694 | + private function purge_error_option_cache( $option, $deleted ) { | |
| 1695 | + wp_cache_delete( $option, 'options' ); | |
| 1696 | + if ( ! $deleted && isset( wp_load_alloptions()[ $option ] ) ) { | |
| 1697 | + wp_cache_delete( 'alloptions', 'options' ); | |
| 1698 | + } | |
| 1699 | + } | |
| 1700 | + | |
| 1701 | + /** | |
| 1540 | 1702 | * Deletes all stored and verified errors for a single error code. |
| 1541 | 1703 | * |
| 1542 | 1704 | * Used by self-healing flows that can positively confirm one specific error |
| 1543 | 1705 | * condition is gone (e.g. a passing connection test clearing |
| @@ -1563,9 +1725,9 @@ | ||
| 1563 | 1725 | if ( isset( $stored_errors[ $error_code ] ) ) { |
| 1564 | 1726 | unset( $stored_errors[ $error_code ] ); |
| 1565 | 1727 | $deleted = true; |
| 1566 | 1728 | if ( count( $stored_errors ) ) { |
| 1567 | - update_option( self::STORED_ERRORS_OPTION, $stored_errors ); | |
| 1729 | + update_option( self::STORED_ERRORS_OPTION, $stored_errors, false ); | |
| 1568 | 1730 | } else { |
| 1569 | 1731 | delete_option( self::STORED_ERRORS_OPTION ); |
| 1570 | 1732 | } |
| 1571 | 1733 | } |
| @@ -1574,9 +1736,9 @@ | ||
| 1574 | 1736 | if ( isset( $verified_errors[ $error_code ] ) ) { |
| 1575 | 1737 | unset( $verified_errors[ $error_code ] ); |
| 1576 | 1738 | $deleted = true; |
| 1577 | 1739 | if ( count( $verified_errors ) ) { |
| 1578 | - update_option( self::STORED_VERIFIED_ERRORS_OPTION, $verified_errors ); | |
| 1740 | + update_option( self::STORED_VERIFIED_ERRORS_OPTION, $verified_errors, false ); | |
| 1579 | 1741 | } else { |
| 1580 | 1742 | delete_option( self::STORED_VERIFIED_ERRORS_OPTION ); |
| 1581 | 1743 | } |
| 1582 | 1744 | } |
| @@ -1581,8 +1743,12 @@ | ||
| 1581 | 1743 | } |
| 1582 | 1744 | } |
| 1583 | 1745 | |
| 1584 | 1746 | if ( $deleted ) { |
| 1747 | + // Per-key purge only: a legacy blob orphan for these codes is cleared on the | |
| 1748 | + // next reconnect via delete_all_errors(), and GC bounds its display meanwhile. | |
| 1749 | + wp_cache_delete( self::STORED_ERRORS_OPTION, 'options' ); | |
| 1750 | + wp_cache_delete( self::STORED_VERIFIED_ERRORS_OPTION, 'options' ); | |
| 1585 | 1751 | $this->invalidate_displayable_errors_cache(); |
| 1586 | 1752 | } |
| 1587 | 1753 | |
| 1588 | 1754 | return $deleted; |
| @@ -1629,9 +1795,9 @@ | ||
| 1629 | 1795 | } |
| 1630 | 1796 | |
| 1631 | 1797 | $verified_errors[ $error_code ][ $user_id ] = $error; |
| 1632 | 1798 | |
| 1633 | - update_option( self::STORED_VERIFIED_ERRORS_OPTION, $verified_errors ); | |
| 1799 | + update_option( self::STORED_VERIFIED_ERRORS_OPTION, $verified_errors, false ); | |
| 1634 | 1800 | |
| 1635 | 1801 | // Invalidate cache since we added a new verified error |
| 1636 | 1802 | $this->invalidate_displayable_errors_cache(); |
| 1637 | 1803 | } |
| @@ -1875,8 +2041,15 @@ | ||
| 1875 | 2041 | * @return void |
| 1876 | 2042 | */ |
| 1877 | 2043 | public function check_signed_request_for_errors( $signing_result, $url, $method, $error_type ) { |
| 1878 | 2044 | if ( ! is_wp_error( $signing_result ) ) { |
| 2045 | + return; | |
| 2046 | + } | |
| 2047 | + | |
| 2048 | + // A site with no registration has no tokens to sign with: failed token lookups | |
| 2049 | + // are expected state there, not connection errors — and a stale cache view that | |
| 2050 | + // hides a connected site's options must not plant a "verified" error either (CONNECT-457). | |
| 2051 | + if ( ! \Jetpack_Options::get_option( 'id' ) ) { | |
| 1879 | 2052 | return; |
| 1880 | 2053 | } |
| 1881 | 2054 | |
| 1882 | 2055 | $data = $signing_result->get_error_data(); |