Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.30% covered (warning)
77.30%
1090 / 1410
55.88% covered (warning)
55.88%
57 / 102
CRAP
0.00% covered (danger)
0.00%
0 / 1
Manager
77.30% covered (warning)
77.30%
1090 / 1410
55.88% covered (warning)
55.88%
57 / 102
3296.63
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 configure
97.62% covered (success)
97.62%
41 / 42
0.00% covered (danger)
0.00%
0 / 1
4
 add_connection_status_invalidation_hooks
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 setup_xmlrpc_handlers
14.81% covered (danger)
14.81%
4 / 27
0.00% covered (danger)
0.00%
0 / 1
85.80
 initialize_rest_api_registration_connector
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 alternate_xmlrpc
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 remove_non_jetpack_xmlrpc_methods
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 require_jetpack_authentication
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 authenticate_jetpack
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 verify_xml_rpc_signature
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 internal_verify_xml_rpc_signature
72.90% covered (warning)
72.90%
78 / 107
0.00% covered (danger)
0.00%
0 / 1
52.39
 get_current_request_transport
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
10
 build_connection_error_data
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 is_active
n/a
0 / 0
n/a
0 / 0
1
 get_tokens
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_registered
n/a
0 / 0
n/a
0 / 0
1
 is_connected
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 reset_connection_status
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 has_connected_admin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 has_connected_user
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_connected_users
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
10
 has_connected_owner
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_userless
n/a
0 / 0
n/a
0 / 0
1
 is_site_connection
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 is_missing_connection_owner
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 is_user_connected
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 get_connection_owner_id
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 get_connected_user_data
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
9
 resolve_wpcom_user_id
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 unbind_wpcom_user_ids_for_new_tokens
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 delete_cached_site_data
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_connected_site_data
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
14
 fetch_connected_site_data
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
10
 get_connection_owner
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
7
 is_connection_owner
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 is_ownership_transferable
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 requires_protected_owner
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 has_protected_owner
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 resolve_protected_owner_state
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
9
 reconcile_protected_owner
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
9
 adopt_protected_owner
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
7.04
 query_protected_owner_record
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 assert_protected_owner_record
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 relinquish_protected_owner_record
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 request_protected_owner_record
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 set_protected_owner
100.00% covered (success)
100.00%
48 / 48
100.00% covered (success)
100.00%
1 / 1
12
 release_protected_owner
87.18% covered (warning)
87.18%
34 / 39
0.00% covered (danger)
0.00%
0 / 1
10.21
 protected_owner_release_refused
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 local_anchor_names_someone_else
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 protected_owner_claimed_by_other
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 clear_protected_owner
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 connect_user
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 disconnect_user_force
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
6.60
 disconnect_all_users_except_primary
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 disconnect_user
87.50% covered (warning)
87.50%
21 / 24
0.00% covered (danger)
0.00%
0 / 1
11.24
 unlink_user_from_wpcom
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 update_connection_owner
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
7
 current_user_may_move_locked_ownership
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 release_anchor_after_transfer
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 update_connection_owner_wpcom
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
4.00
 api_url
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 xmlrpc_api_url
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 register
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 do_register
87.74% covered (warning)
87.74%
93 / 106
0.00% covered (danger)
0.00%
0 / 1
14.36
 try_registration
93.55% covered (success)
93.55%
29 / 31
0.00% covered (danger)
0.00%
0 / 1
9.02
 get_registration_failure
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 clear_registration_failure
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_plugin_slug
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 add_register_request_param
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 validate_remote_register_response
70.83% covered (warning)
70.83%
34 / 48
0.00% covered (danger)
0.00%
0 / 1
17.19
 add_nonce
n/a
0 / 0
n/a
0 / 0
1
 clean_nonces
n/a
0 / 0
n/a
0 / 0
2
 jetpack_connection_custom_caps
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
10
 get_max_execution_time
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 set_min_time_limit
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 get_assumed_site_creation_date
96.43% covered (success)
96.43%
27 / 28
0.00% covered (danger)
0.00%
0 / 1
3
 apply_activation_source_to_args
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 generate_secrets
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_secrets
n/a
0 / 0
n/a
0 / 0
1
 delete_secrets
n/a
0 / 0
n/a
0 / 0
1
 delete_all_connection_tokens
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
5
 disconnect_site_wpcom
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
7.07
 remove_connection
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 reconnect
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 restore
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
10.02
 handle_registration
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 validate_tokens
n/a
0 / 0
n/a
0 / 0
1
 verify_secrets
n/a
0 / 0
n/a
0 / 0
1
 handle_authorization
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_token
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_authorization_url
96.36% covered (success)
96.36%
53 / 55
0.00% covered (danger)
0.00%
0 / 1
9
 authorize
68.42% covered (warning)
68.42%
26 / 38
0.00% covered (danger)
0.00%
0 / 1
18.32
 disconnect_site
62.50% covered (warning)
62.50%
15 / 24
0.00% covered (danger)
0.00%
0 / 1
11.38
 sha1_base64
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 is_usable_domain
0.00% covered (danger)
0.00%
0 / 65
0.00% covered (danger)
0.00%
0 / 1
90
 get_access_token
n/a
0 / 0
n/a
0 / 0
1
 xmlrpc_methods
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 reset_raw_post_data
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 public_xmlrpc_methods
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 jetpack_get_options
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
12
 xmlrpc_options
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 reset_saved_auth_state
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sign_role
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 set_plugin_instance
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_plugin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_connected_plugins
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 disable_plugin
n/a
0 / 0
n/a
0 / 0
1
 enable_plugin
n/a
0 / 0
n/a
0 / 0
1
 is_plugin_enabled
n/a
0 / 0
n/a
0 / 0
1
 refresh_blog_token
76.67% covered (warning)
76.67%
23 / 30
0.00% covered (danger)
0.00%
0 / 1
10.03
 refresh_user_token
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
13
 get_signed_token
n/a
0 / 0
n/a
0 / 0
1
 add_stats_to_heartbeat
81.25% covered (warning)
81.25%
13 / 16
0.00% covered (danger)
0.00%
0 / 1
7.32
 track_xmlrpc_error
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 get_site_id
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 is_ready_for_cleanup
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2/**
3 * The Jetpack Connection manager class file.
4 *
5 * @package automattic/jetpack-connection
6 */
7
8namespace Automattic\Jetpack\Connection;
9
10use Automattic\Jetpack\A8c_Mc_Stats;
11use Automattic\Jetpack\Constants;
12use Automattic\Jetpack\Heartbeat;
13use Automattic\Jetpack\Identity_Crisis;
14use Automattic\Jetpack\Partner;
15use Automattic\Jetpack\Roles;
16use Automattic\Jetpack\Status;
17use Automattic\Jetpack\Status\Host;
18use Automattic\Jetpack\Terms_Of_Service;
19use Automattic\Jetpack\Tracking;
20use IXR_Error;
21use Jetpack_IXR_Client;
22use Jetpack_Options;
23use Jetpack_XMLRPC_Server;
24use WP_Error;
25use WP_User;
26
27/**
28 * The Jetpack Connection Manager class that is used as a single gateway between WordPress.com
29 * and Jetpack.
30 */
31class Manager {
32    /**
33     * Prefix of the transient holding the cached WordPress.com site record. The blog ID is
34     * appended so a reconnect to a different site cannot read the previous site's record.
35     *
36     * @since 9.0.0
37     *
38     * @var string
39     */
40    const SITE_DATA_TRANSIENT_PREFIX = 'jetpack_site_data_';
41
42    /**
43     * Why `has_protected_owner()` answered false, and what would change it.
44     *
45     * `RE_EVALUATE` is the race: the gate said false, and by the time the state was classified the
46     * owner matched after all. It is not a problem to report, it is an instruction to ask again.
47     *
48     * @since 9.4.0
49     */
50    const PO_STATE_NOT_ELIGIBLE               = 'NOT_ELIGIBLE';
51    const PO_STATE_NEEDS_CONNECT_TO_ESTABLISH = 'NEEDS_CONNECT_TO_ESTABLISH';
52    const PO_STATE_CAN_ESTABLISH              = 'CAN_ESTABLISH';
53    const PO_STATE_NEEDS_OWNER_RECONNECT      = 'NEEDS_OWNER_RECONNECT';
54    const PO_STATE_NEEDS_DIFFERENT_OWNER      = 'NEEDS_DIFFERENT_OWNER';
55    const PO_STATE_RE_EVALUATE                = 'RE_EVALUATE';
56
57    /**
58     * A copy of the raw POST data for signature verification purposes.
59     *
60     * @var string
61     */
62    protected $raw_post_data;
63
64    /**
65     * Verification data needs to be stored to properly verify everything.
66     *
67     * @var Object
68     */
69    private $xmlrpc_verification = null;
70
71    /**
72     * Plugin management object.
73     *
74     * @var Plugin
75     */
76    private $plugin = null;
77
78    /**
79     * Error handler object.
80     *
81     * @var Error_Handler
82     */
83    public $error_handler = null;
84
85    /**
86     * Jetpack_XMLRPC_Server object
87     *
88     * @var Jetpack_XMLRPC_Server
89     */
90    public $xmlrpc_server = null;
91
92    /**
93     * Holds extra parameters that will be sent along in the register request body.
94     *
95     * Use Manager::add_register_request_param to add values to this array.
96     *
97     * @since 1.26.0
98     * @var array
99     */
100    private static $extra_register_params = array();
101
102    /**
103     * We store ID's of users already disconnected to prevent multiple disconnect requests.
104     *
105     * @var array
106     */
107    private static $disconnected_users = array();
108
109    /**
110     * Cached connection status.
111     *
112     * @var bool|null True if the site is connected, false if not, null if not determined yet.
113     */
114    private static $is_connected = null;
115
116    /**
117     * Memoized user ID of the connection owner.
118     * If undefined or invalid, set to 0.
119     *
120     * @var null|int
121     */
122    private static $connection_owner_id = null;
123
124    /**
125     * Tracks whether connection status invalidation hooks have been added.
126     *
127     * @var bool
128     */
129    private static $connection_invalidators_added = false;
130
131    /**
132     * The raw HTTP response of the registration request currently being processed.
133     *
134     * @var array|WP_Error|null
135     */
136    private $last_register_response = null;
137
138    /**
139     * Initialize the object.
140     * Make sure to call the "Configure" first.
141     *
142     * @param string $plugin_slug Slug of the plugin using the connection (optional, but encouraged).
143     *
144     * @see \Automattic\Jetpack\Config
145     */
146    public function __construct( $plugin_slug = null ) {
147        if ( $plugin_slug && is_string( $plugin_slug ) ) {
148            $this->set_plugin_instance( new Plugin( $plugin_slug ) );
149        }
150    }
151
152    /**
153     * Initializes required listeners. This is done separately from the constructors
154     * because some objects sometimes need to instantiate separate objects of this class.
155     *
156     * @todo Implement a proper nonce verification.
157     */
158    public static function configure() {
159        $manager = new self();
160
161        add_filter(
162            'jetpack_constant_default_value',
163            __NAMESPACE__ . '\Utils::jetpack_api_constant_filter',
164            10,
165            2
166        );
167
168        $manager->setup_xmlrpc_handlers(
169            null,
170            $manager->has_connected_owner(),
171            $manager->verify_xml_rpc_signature()
172        );
173
174        $manager->error_handler = Error_Handler::get_instance();
175
176        if ( $manager->is_connected() ) {
177            add_filter( 'xmlrpc_methods', array( $manager, 'public_xmlrpc_methods' ) );
178            add_filter( 'shutdown', array( Package_Version_Tracker::class, 'update_on_shutdown' ) );
179        }
180
181        // This runs on priority 11 - at least one api method in the connection package is set to override a previously
182        // existing method from the Jetpack plugin. Running later than Jetpack's api init ensures the override is successful.
183        add_action( 'rest_api_init', array( $manager, 'initialize_rest_api_registration_connector' ), 11 );
184
185        ( new Nonce_Handler() )->init_schedule();
186
187        add_action( 'plugins_loaded', __NAMESPACE__ . '\Plugin_Storage::configure', 100 );
188
189        add_filter( 'map_meta_cap', array( $manager, 'jetpack_connection_custom_caps' ), 1, 4 );
190
191        Heartbeat::init();
192        add_filter( 'jetpack_heartbeat_stats_array', array( $manager, 'add_stats_to_heartbeat' ) );
193        add_action( 'jetpack_verify_signature_error', array( $manager, 'track_xmlrpc_error' ) );
194
195        Webhooks::init( $manager );
196
197        add_action( 'pre_update_jetpack_option_user_tokens', array( $manager, 'unbind_wpcom_user_ids_for_new_tokens' ), 10, 2 );
198        add_action( 'jetpack_user_authorized', array( $manager, 'reconcile_protected_owner' ) );
199
200        // Unlink user before deleting the user from WP.com.
201        add_action( 'deleted_user', array( $manager, 'disconnect_user_force' ), 9, 1 );
202        add_action( 'remove_user_from_blog', array( $manager, 'disconnect_user_force' ), 9, 1 );
203
204        // Add hooks for cleaning up account mismatch transients
205        $user_account_status = new User_Account_Status();
206        add_action( 'delete_user', array( $user_account_status, 'clean_account_mismatch_transients' ), 9, 1 );
207        add_action( 'remove_user_from_blog', array( $user_account_status, 'clean_account_mismatch_transients' ), 9, 1 );
208        add_action( 'user_register', array( $user_account_status, 'clean_account_mismatch_transients' ), 9, 1 );
209        add_action( 'profile_update', array( $user_account_status, 'clean_account_mismatch_transients' ), 9, 1 );
210
211        $manager->add_connection_status_invalidation_hooks();
212
213        // Set up package version hook.
214        add_filter( 'jetpack_package_versions', __NAMESPACE__ . '\Package_Version::send_package_version_to_tracker' );
215
216        if ( defined( 'JETPACK__SANDBOX_DOMAIN' ) && JETPACK__SANDBOX_DOMAIN ) {
217            ( new Server_Sandbox() )->init();
218        }
219
220        // Initialize connection notices.
221        new Connection_Notice();
222
223        // Initialize token locks.
224        new Tokens_Locks();
225
226        // Initial Partner management.
227        Partner::init();
228
229        // WP 7.0+ Connectors screen card.
230        Jetpack_Connector::init();
231
232        // Site Health integration.
233        Site_Health::init();
234    }
235
236    /**
237     * Adds hooks to invalidate the memoized connection status.
238     */
239    private function add_connection_status_invalidation_hooks() {
240        if ( self::$connection_invalidators_added ) {
241            return;
242        }
243
244        // Force is_connected() to recompute after important actions.
245        add_action( 'jetpack_site_registered', array( $this, 'reset_connection_status' ) );
246        add_action( 'jetpack_site_disconnected', array( $this, 'reset_connection_status' ) );
247        // Deletion doesn't fire `pre_update_jetpack_option_*`; see the action's docblock in `Tokens::delete_all()`.
248        add_action( 'jetpack_connection_tokens_deleted', array( $this, 'reset_connection_status' ) );
249        add_action( 'jetpack_sync_register_user', array( $this, 'reset_connection_status' ) );
250        add_action( 'pre_update_jetpack_option_id', array( $this, 'reset_connection_status' ) );
251        add_action( 'pre_update_jetpack_option_blog_token', array( $this, 'reset_connection_status' ) );
252        add_action( 'pre_update_jetpack_option_user_token', array( $this, 'reset_connection_status' ) );
253        add_action( 'pre_update_jetpack_option_user_tokens', array( $this, 'reset_connection_status' ) );
254        add_action( 'pre_update_jetpack_option_master_user', array( $this, 'reset_connection_status' ) );
255        // phpcs:ignore WPCUT.SwitchBlog.SwitchBlog -- wpcom flags **every** use of switch_blog, apparently expecting valid instances to ignore or suppress the sniff.
256        add_action( 'switch_blog', array( $this, 'reset_connection_status' ) );
257        add_action( 'jetpack_external_storage_provider_registered', array( $this, 'reset_connection_status' ), 10, 0 );
258
259        self::$connection_invalidators_added = true;
260    }
261
262    /**
263     * Sets up the XMLRPC request handlers.
264     *
265     * @since 1.25.0 Deprecate $is_active param.
266     * @since 2.8.4 Deprecate $request_params param.
267     *
268     * @param array|null            $deprecated Deprecated. Not used.
269     * @param bool                  $has_connected_owner Whether the site has a connected owner.
270     * @param bool                  $is_signed whether the signature check has been successful.
271     * @param Jetpack_XMLRPC_Server $xmlrpc_server (optional) an instance of the server to use instead of instantiating a new one.
272     */
273    public function setup_xmlrpc_handlers(
274        $deprecated,
275        $has_connected_owner,
276        $is_signed,
277        ?Jetpack_XMLRPC_Server $xmlrpc_server = null
278    ) {
279        add_filter( 'xmlrpc_blog_options', array( $this, 'xmlrpc_options' ), 1000, 2 );
280        if ( $deprecated !== null ) {
281            _deprecated_argument( __METHOD__, '2.8.4' );
282        }
283        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- We are using the 'for' request param to early return unless it's 'jetpack'.
284        if ( ! isset( $_GET['for'] ) || 'jetpack' !== $_GET['for'] ) {
285            return false;
286        }
287
288        // Alternate XML-RPC, via ?for=jetpack&jetpack=comms.
289        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This just determines whether to handle the request as an XML-RPC request. The actual XML-RPC endpoints do the appropriate nonce checking where applicable. Plus we make sure to clear all cookies via require_jetpack_authentication called later in method.
290        if ( isset( $_GET['jetpack'] ) && 'comms' === $_GET['jetpack'] ) {
291            if ( ! Constants::is_defined( 'XMLRPC_REQUEST' ) ) {
292                // Use the real constant here for WordPress' sake.
293                define( 'XMLRPC_REQUEST', true );
294            }
295
296            add_action( 'template_redirect', array( $this, 'alternate_xmlrpc' ) );
297
298            add_filter( 'xmlrpc_methods', array( $this, 'remove_non_jetpack_xmlrpc_methods' ), 1000 );
299        }
300
301        if ( ! Constants::get_constant( 'XMLRPC_REQUEST' ) ) {
302            return false;
303        }
304
305        // Display errors can cause the XML to be not well formed.
306        // This only affects Jetpack XML-RPC endpoints received from WordPress.com servers.
307        // All other XML-RPC requests are unaffected.
308        @ini_set( 'display_errors', false ); // phpcs:ignore
309
310        if ( $xmlrpc_server ) {
311            $this->xmlrpc_server = $xmlrpc_server;
312        } else {
313            $this->xmlrpc_server = new Jetpack_XMLRPC_Server();
314        }
315
316        $this->require_jetpack_authentication();
317
318        if ( $is_signed ) {
319            // If the site is connected either at a site or user level and the request is signed, expose the methods.
320            // The callback is responsible to determine whether the request is signed with blog or user token and act accordingly.
321            // The actual API methods.
322            $callback = array( $this->xmlrpc_server, 'xmlrpc_methods' );
323
324            // Hack to preserve $HTTP_RAW_POST_DATA.
325            add_filter( 'xmlrpc_methods', array( $this, 'xmlrpc_methods' ) );
326
327        } elseif ( $has_connected_owner ) {
328            // The jetpack.authorize method should be available for unauthenticated users on a site with an
329            // active Jetpack connection, so that additional users can link their account.
330            $callback = array( $this->xmlrpc_server, 'authorize_xmlrpc_methods' );
331        } else {
332            // Any other unsigned request should expose the bootstrap methods.
333            $callback = array( $this->xmlrpc_server, 'bootstrap_xmlrpc_methods' );
334            new XMLRPC_Connector( $this );
335        }
336
337        add_filter( 'xmlrpc_methods', $callback );
338
339        // Now that no one can authenticate, and we're whitelisting all XML-RPC methods, force enable_xmlrpc on.
340        add_filter( 'pre_option_enable_xmlrpc', '__return_true' );
341        return true;
342    }
343
344    /**
345     * Initializes the REST API connector on the init hook.
346     */
347    public function initialize_rest_api_registration_connector() {
348        new REST_Connector( $this );
349    }
350
351    /**
352     * Since a lot of hosts use a hammer approach to "protecting" WordPress sites,
353     * and just blanket block all requests to /xmlrpc.php, or apply other overly-sensitive
354     * security/firewall policies, we provide our own alternate XML RPC API endpoint
355     * which is accessible via a different URI. Most of the below is copied directly
356     * from /xmlrpc.php so that we're replicating it as closely as possible.
357     *
358     * @todo Tighten $wp_xmlrpc_server_class a bit to make sure it doesn't do bad things.
359     *
360     * @return never
361     */
362    public function alternate_xmlrpc() {
363        // Some browser-embedded clients send cookies. We don't want them.
364        $_COOKIE = array();
365
366        include_once ABSPATH . 'wp-admin/includes/admin.php';
367        include_once ABSPATH . WPINC . '/class-IXR.php';
368        include_once ABSPATH . WPINC . '/class-wp-xmlrpc-server.php';
369
370        /**
371         * Filters the class used for handling XML-RPC requests.
372         *
373         * @since 1.7.0
374         * @since-jetpack 3.1.0
375         *
376         * @param string $class The name of the XML-RPC server class.
377         */
378        $wp_xmlrpc_server_class = apply_filters( 'wp_xmlrpc_server_class', 'wp_xmlrpc_server' );
379        $wp_xmlrpc_server       = new $wp_xmlrpc_server_class();
380
381        // Fire off the request.
382        nocache_headers();
383        $wp_xmlrpc_server->serve_request();
384
385        exit( 0 );
386    }
387
388    /**
389     * Removes all XML-RPC methods that are not `jetpack.*`.
390     * Only used in our alternate XML-RPC endpoint, where we want to
391     * ensure that Core and other plugins' methods are not exposed.
392     *
393     * @param array $methods a list of registered WordPress XMLRPC methods.
394     * @return array filtered $methods
395     */
396    public function remove_non_jetpack_xmlrpc_methods( $methods ) {
397        $jetpack_methods = array();
398
399        foreach ( $methods as $method => $callback ) {
400            if ( str_starts_with( $method, 'jetpack.' ) ) {
401                $jetpack_methods[ $method ] = $callback;
402            }
403        }
404
405        return $jetpack_methods;
406    }
407
408    /**
409     * Removes all other authentication methods not to allow other
410     * methods to validate unauthenticated requests.
411     */
412    public function require_jetpack_authentication() {
413        // Don't let anyone authenticate.
414        $_COOKIE = array();
415        remove_all_filters( 'authenticate' );
416        remove_all_actions( 'wp_login_failed' );
417
418        if ( $this->is_connected() ) {
419            // Allow Jetpack authentication.
420            add_filter( 'authenticate', array( $this, 'authenticate_jetpack' ), 10, 3 );
421        }
422    }
423
424    /**
425     * Authenticates XML-RPC and other requests from the Jetpack Server
426     *
427     * @param WP_User|mixed $user user object if authenticated.
428     * @param string        $username username.
429     * @param string        $password password string.
430     * @return WP_User|mixed authenticated user or error.
431     */
432    public function authenticate_jetpack( $user, $username, $password ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
433        if ( is_a( $user, '\\WP_User' ) ) {
434            return $user;
435        }
436
437        $token_details = $this->verify_xml_rpc_signature();
438
439        if ( ! $token_details ) {
440            return $user;
441        }
442
443        if ( 'user' !== $token_details['type'] ) {
444            return $user;
445        }
446
447        if ( ! $token_details['user_id'] ) {
448            return $user;
449        }
450
451        nocache_headers();
452
453        return new \WP_User( $token_details['user_id'] );
454    }
455
456    /**
457     * Verifies the signature of the current request.
458     *
459     * @return false|array
460     */
461    public function verify_xml_rpc_signature() {
462        if ( $this->xmlrpc_verification === null ) {
463            $this->xmlrpc_verification = $this->internal_verify_xml_rpc_signature();
464
465            if ( is_wp_error( $this->xmlrpc_verification ) ) {
466                /**
467                 * Action for logging XMLRPC signature verification errors. This data is sensitive.
468                 *
469                 * @since 1.7.0
470                 * @since-jetpack 7.5.0
471                 *
472                 * @param WP_Error $signature_verification_error The verification error
473                 */
474                do_action( 'jetpack_verify_signature_error', $this->xmlrpc_verification );
475
476                Error_Handler::get_instance()->report_error( $this->xmlrpc_verification );
477
478            }
479        }
480
481        return is_wp_error( $this->xmlrpc_verification ) ? false : $this->xmlrpc_verification;
482    }
483
484    /**
485     * Verifies the signature of the current request.
486     *
487     * This function has side effects and should not be used. Instead,
488     * use the memoized version `->verify_xml_rpc_signature()`.
489     *
490     * @internal
491     * @todo Refactor to use proper nonce verification.
492     */
493    private function internal_verify_xml_rpc_signature() {
494        // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
495        // It's not for us.
496        if ( ! isset( $_GET['token'] ) || empty( $_GET['signature'] ) ) {
497            return false;
498        }
499
500        // Skip XML-RPC signature verification for OAuth authorization flow.
501        // OAuth uses GET requests without body-hash and has its own
502        // signature verification in Authorize_Json_Api class.
503        if ( isset( $_GET['action'] ) && $_GET['action'] === 'jetpack_json_api_authorization' ) {
504            return false;
505        }
506
507        $signature_details = array(
508            'token'     => isset( $_GET['token'] ) ? wp_unslash( $_GET['token'] ) : '',
509            'timestamp' => isset( $_GET['timestamp'] ) ? wp_unslash( $_GET['timestamp'] ) : '',
510            'nonce'     => isset( $_GET['nonce'] ) ? wp_unslash( $_GET['nonce'] ) : '',
511            'body_hash' => isset( $_GET['body-hash'] ) ? wp_unslash( $_GET['body-hash'] ) : '',
512            'method'    => isset( $_SERVER['REQUEST_METHOD'] ) ? wp_unslash( $_SERVER['REQUEST_METHOD'] ) : null,
513            'url'       => wp_unslash( ( $_SERVER['HTTP_HOST'] ?? null ) . ( $_SERVER['REQUEST_URI'] ?? null ) ), // Temp - will get real signature URL later.
514            'signature' => isset( $_GET['signature'] ) ? wp_unslash( $_GET['signature'] ) : '',
515        );
516
517        // Transport of the incoming request being verified. This signature-verification path
518        // serves both XML-RPC requests and signed REST requests (REST_Authentication funnels
519        // REST authentication into verify_xml_rpc_signature()), so the stored error type is
520        // derived from the actual request context rather than hardcoded.
521        $error_type      = $this->get_current_request_transport();
522        $error_direction = 'incoming'; // Matches Error_Handler::DIRECTION_INCOMING â€” see build_connection_error_data() for why the constant is not referenced.
523
524        // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
525        @list( $token_key, $version, $user_id ) = explode( ':', wp_unslash( $_GET['token'] ) );
526        // phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
527
528        $jetpack_api_version = Constants::get_constant( 'JETPACK__API_VERSION' );
529
530        if (
531            empty( $token_key )
532                || empty( $version )
533                || (string) $jetpack_api_version !== $version
534        ) {
535            return new \WP_Error( 'malformed_token', 'Malformed token in request', $this->build_connection_error_data( $signature_details, $error_type, $error_direction ) );
536        }
537
538        if ( '0' === $user_id ) {
539            $token_type = 'blog';
540            $user_id    = 0;
541        } else {
542            $token_type = 'user';
543            if ( empty( $user_id ) || ! ctype_digit( $user_id ) ) {
544                return new \WP_Error(
545                    'malformed_user_id',
546                    'Malformed user_id in request',
547                    $this->build_connection_error_data( $signature_details, $error_type, $error_direction )
548                );
549            }
550            $user_id = (int) $user_id;
551
552            $user = new \WP_User( $user_id );
553            if ( ! $user->exists() ) {
554                return new \WP_Error(
555                    'unknown_user',
556                    sprintf( 'User %d does not exist', $user_id ),
557                    $this->build_connection_error_data( $signature_details, $error_type, $error_direction )
558                );
559            }
560        }
561
562        $token = $this->get_tokens()->get_access_token( $user_id, $token_key, false );
563        if ( is_wp_error( $token ) ) {
564            $token->add_data( $this->build_connection_error_data( $signature_details, $error_type, $error_direction ) );
565            return $token;
566        } elseif ( ! $token ) {
567            // `get_access_token()` explains itself for every case but one: it returns a bare
568            // `false` when the tokens are locked (Tokens::is_locked()). The lock is
569            // one-shot and self-healing.
570            return new \WP_Error(
571                'tokens_locked',
572                sprintf( 'Tokens are locked; %s:%s:%d could not be verified', $token_key, $version, $user_id ),
573                $this->build_connection_error_data( $signature_details, $error_type, $error_direction )
574            );
575        }
576
577        $jetpack_signature = new \Jetpack_Signature( $token->secret, (int) \Jetpack_Options::get_option( 'time_diff' ) );
578        // phpcs:disable WordPress.Security.NonceVerification.Missing -- Used to verify a cryptographic signature of the post data. Also a nonce is verified later in the function.
579        if ( isset( $_POST['_jetpack_is_multipart'] ) ) {
580            $post_data   = $_POST; // We need all of $_POST in order to verify a cryptographic signature of the post data.
581            $file_hashes = array();
582            foreach ( $post_data as $post_data_key => $post_data_value ) {
583                if ( ! str_starts_with( $post_data_key, '_jetpack_file_hmac_' ) ) {
584                    continue;
585                }
586                $post_data_key                 = substr( $post_data_key, strlen( '_jetpack_file_hmac_' ) );
587                $file_hashes[ $post_data_key ] = $post_data_value;
588            }
589
590            foreach ( $file_hashes as $post_data_key => $post_data_value ) {
591                unset( $post_data[ "_jetpack_file_hmac_{$post_data_key}" ] );
592                $post_data[ $post_data_key ] = $post_data_value;
593            }
594
595            ksort( $post_data );
596
597            $body = http_build_query( stripslashes_deep( $post_data ) );
598        } elseif ( $this->raw_post_data === null ) {
599            $body = file_get_contents( 'php://input' );
600        } else {
601            $body = null;
602        }
603        // phpcs:enable
604
605        $signature = $jetpack_signature->sign_current_request(
606            array( 'body' => $body === null ? $this->raw_post_data : $body )
607        );
608
609        $signature_details['url'] = $jetpack_signature->current_request_url;
610
611        // This path currently can't ever be true, unless a new path is added resulting
612        // in $signature 'false', null or an empty string. Leaving for additional security.
613        if ( ! $signature ) {
614            return new \WP_Error(
615                'could_not_sign',
616                'Unknown signature error',
617                $this->build_connection_error_data( $signature_details, $error_type, $error_direction )
618            );
619        } elseif ( is_wp_error( $signature ) ) {
620            // Jetpack_Signature errors carry their own signature_details (or, for some codes,
621            // no data at all) but never a type or direction; normalize them into the standard
622            // error data shape so Error_Handler can attribute and store them.
623            $signature_error_data = $signature->get_error_data();
624            if ( isset( $signature_error_data['signature_details'] ) && is_array( $signature_error_data['signature_details'] ) ) {
625                $signature_details = array_merge( $signature_details, $signature_error_data['signature_details'] );
626            }
627            $signature->add_data( $this->build_connection_error_data( $signature_details, $error_type, $error_direction ) );
628            return $signature;
629        }
630
631        // phpcs:disable WordPress.Security.NonceVerification.Recommended
632        $timestamp = (int) $_GET['timestamp'];
633        $nonce     = wp_unslash( (string) $_GET['nonce'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- WP Core doesn't sanitize nonces either.
634        // phpcs:enable WordPress.Security.NonceVerification.Recommended
635
636        // Use up the nonce regardless of whether the signature matches.
637        if ( ! ( new Nonce_Handler() )->add( $timestamp, $nonce ) ) {
638            return new \WP_Error(
639                'invalid_nonce',
640                'Could not add nonce',
641                $this->build_connection_error_data( $signature_details, $error_type, $error_direction )
642            );
643        }
644
645        // Be careful about what you do with this debugging data.
646        // If a malicious requester has access to the expected signature,
647        // bad things might be possible.
648        $signature_details['expected'] = $signature;
649
650        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
651        if ( ! hash_equals( $signature, wp_unslash( $_GET['signature'] ) ) ) {
652            return new \WP_Error(
653                'signature_mismatch',
654                'Signature mismatch',
655                $this->build_connection_error_data( $signature_details, $error_type, $error_direction )
656            );
657        }
658
659        /**
660         * Action for additional token checking.
661         *
662         * @since 1.7.0
663         * @since-jetpack 7.7.0
664         *
665         * @param array $post_data request data.
666         * @param array $token_data token data.
667         */
668        return apply_filters(
669            'jetpack_signature_check_token',
670            array(
671                'type'      => $token_type,
672                'token_key' => $token_key,
673                'user_id'   => $token->external_user_id,
674            ),
675            $token,
676            $this->raw_post_data
677        );
678    }
679
680    /**
681     * Determines the transport of the incoming request currently being verified.
682     *
683     * @since 8.9.0
684     *
685     * @return string Error_Handler::ERROR_TYPE_XMLRPC or Error_Handler::ERROR_TYPE_REST.
686     */
687    private function get_current_request_transport() {
688        $is_xmlrpc = defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST;
689
690        // XMLRPC_REQUEST covers both /xmlrpc.php and the alternate XML-RPC endpoint, which
691        // defines the constant itself (see setup_xmlrpc_handlers). Outside those, signed REST
692        // requests are detected via the REST dispatch state. Anything else (e.g. signed
693        // requests verified on the 'authenticate' filter for regular URLs) keeps the historic
694        // XML-RPC label rather than guessing at a transport.
695        $is_rest = ! $is_xmlrpc && ( function_exists( 'wp_is_rest_endpoint' ) ? wp_is_rest_endpoint() : ( defined( 'REST_REQUEST' ) && REST_REQUEST ) );
696
697        // Signature verification can run before REST dispatch is set up: REST_Authentication
698        // hooks `determine_current_user`, which any plugin can trigger early (e.g. by calling
699        // wp_get_current_user() on plugins_loaded), before the REST_REQUEST constant exists.
700        // In that window, recognize REST requests by their URL: the REST prefix in the path,
701        // or the rest_route query argument used by sites without pretty permalinks.
702        if ( ! $is_xmlrpc && ! $is_rest ) {
703            // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only used to classify the request transport.
704            $has_rest_route_arg = isset( $_GET['rest_route'] );
705            $request_path       = (string) wp_parse_url( isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '', PHP_URL_PATH ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Parsed for path comparison only.
706            $is_rest            = $has_rest_route_arg || false !== strpos( $request_path, '/' . rest_get_url_prefix() . '/' );
707        }
708
709        // The literals match Error_Handler::ERROR_TYPE_REST / ERROR_TYPE_XMLRPC â€” see
710        // build_connection_error_data() for why the constants are not referenced.
711        return $is_rest ? 'rest' : 'xmlrpc';
712    }
713
714    /**
715     * Builds the standardized connection error data attached to signature-verification errors.
716     *
717     * Wraps `Error_Handler::build_connection_error_data()`, falling back to the legacy
718     * error-data shape when the loaded Error_Handler predates that method: during a plugin
719     * update, an older version of the class can already be in memory while this file is the
720     * new one, and a mid-update request must never fatal. For the same reason, code in this
721     * class must not reference Error_Handler constants introduced along with that method
722     * ('xmlrpc', 'rest', 'local_state', 'incoming', 'outgoing') â€” use the literal values.
723     *
724     * @since 8.10.0
725     *
726     * @param array  $signature_details Details of the request signature being verified.
727     * @param string $error_type        The transport of the request: 'xmlrpc' or 'rest'.
728     * @param string $error_direction   The direction of the request: 'incoming' or 'outgoing'.
729     * @return array Error data for `WP_Error`.
730     */
731    private function build_connection_error_data( $signature_details, $error_type, $error_direction ) {
732        if ( ! method_exists( Error_Handler::class, 'build_connection_error_data' ) ) {
733            return compact( 'signature_details', 'error_type' );
734        }
735        return Error_Handler::build_connection_error_data( $signature_details, $error_type, $error_direction );
736    }
737
738    /**
739     * Returns true if the current site is connected to WordPress.com and has the minimum requirements to enable Jetpack UI.
740     *
741     * This method is deprecated since version 1.25.0 of this package. Please use has_connected_owner instead.
742     *
743     * Since this method has a wide spread use, we decided not to throw any deprecation warnings for now.
744     *
745     * @deprecated 1.25.0
746     * @see Manager::has_connected_owner
747     * @return bool is the site connected?
748     */
749    public function is_active() {
750        return (bool) $this->get_tokens()->get_access_token( true );
751    }
752
753    /**
754     * Obtains an instance of the Tokens class.
755     *
756     * @return Tokens the Tokens object
757     */
758    public function get_tokens() {
759        return new Tokens();
760    }
761
762    /**
763     * Returns true if the site has both a token and a blog id, which indicates a site has been registered.
764     *
765     * @access public
766     * @deprecated 1.12.1 Use is_connected instead
767     * @see Manager::is_connected
768     *
769     * @return bool
770     */
771    public function is_registered() {
772        _deprecated_function( __METHOD__, '1.12.1' );
773        return $this->is_connected();
774    }
775
776    /**
777     * Returns true if the site has both a token and a blog id, which indicates a site has been connected.
778     *
779     * @access public
780     * @since 1.21.1
781     *
782     * @return bool
783     */
784    public function is_connected() {
785        if ( self::$is_connected === null ) {
786            if ( ! self::$connection_invalidators_added ) {
787                $this->add_connection_status_invalidation_hooks();
788            }
789
790            $has_blog_id = (bool) \Jetpack_Options::get_option( 'id' );
791            if ( $has_blog_id ) {
792                self::$is_connected = (bool) $this->get_tokens()->get_access_token();
793            } else {
794                // Short-circuit, no need to check for tokens if there's no blog ID.
795                self::$is_connected = false;
796            }
797        }
798        return self::$is_connected;
799    }
800
801    /**
802     * Resets the memoized connection status.
803     * This will force the connection status to be recomputed on the next check.
804     *
805     * @since 5.0.0
806     */
807    public function reset_connection_status() {
808        self::$is_connected        = null;
809        self::$connection_owner_id = null;
810    }
811
812    /**
813     * Returns true if the site has at least one connected administrator.
814     *
815     * @access public
816     * @since 1.21.1
817     *
818     * @return bool
819     */
820    public function has_connected_admin() {
821        return (bool) count( $this->get_connected_users( 'manage_options' ) );
822    }
823
824    /**
825     * Returns true if the site has any connected user.
826     *
827     * @access public
828     * @since 1.21.1
829     *
830     * @return bool
831     */
832    public function has_connected_user() {
833        return (bool) count( $this->get_connected_users( 'any', 1 ) );
834    }
835
836    /**
837     * Returns an array of users that have user tokens for communicating with wpcom.
838     * Able to select by specific capability.
839     *
840     * @since 9.9.1 Added $limit parameter.
841     *
842     * @param string   $capability The capability of the user.
843     * @param int|null $limit How many connected users to get before returning.
844     * @return WP_User[] Array of WP_User objects if found.
845     */
846    public function get_connected_users( $capability = 'any', $limit = null ) {
847        $connected_users = array();
848        $user_tokens     = $this->get_tokens()->get_user_tokens();
849
850        if ( ! is_array( $user_tokens ) || empty( $user_tokens ) ) {
851            return $connected_users;
852        }
853        $connected_user_ids = array_keys( $user_tokens );
854
855        if ( ! empty( $connected_user_ids ) ) {
856            foreach ( $connected_user_ids as $id ) {
857                // Check for capability.
858                if ( 'any' !== $capability && ! user_can( $id, $capability ) ) {
859                    continue;
860                }
861
862                $user_data = get_userdata( $id );
863                if ( $user_data instanceof \WP_User ) {
864                    $connected_users[] = $user_data;
865                    if ( $limit && count( $connected_users ) >= $limit ) {
866                        return $connected_users;
867                    }
868                }
869            }
870        }
871
872        return $connected_users;
873    }
874
875    /**
876     * Returns true if the site has a connected Blog owner (master_user).
877     *
878     * @access public
879     * @since 1.21.1
880     *
881     * @return bool
882     */
883    public function has_connected_owner() {
884        return (bool) $this->get_connection_owner_id();
885    }
886
887    /**
888     * Returns true if the site is connected only at a site level.
889     *
890     * Note that we are explicitly checking for the existence of the master_user option in order to account for cases where we don't have any user tokens (user-level connection) but the master_user option is set, which could be the result of a problematic user connection.
891     *
892     * @access public
893     * @since 1.25.0
894     * @deprecated 1.27.0
895     *
896     * @return bool
897     */
898    public function is_userless() {
899        _deprecated_function( __METHOD__, '1.27.0', 'Automattic\\Jetpack\\Connection\\Manager::is_site_connection' );
900        return $this->is_site_connection();
901    }
902
903    /**
904     * Returns true if the site is connected only at a site level.
905     *
906     * Note that we are explicitly checking for the existence of the master_user option in order to account for cases where we don't have any user tokens (user-level connection) but the master_user option is set, which could be the result of a problematic user connection.
907     *
908     * @access public
909     * @since 1.27.0
910     *
911     * @return bool
912     */
913    public function is_site_connection() {
914        return $this->is_connected() && ! $this->has_connected_user() && ! \Jetpack_Options::get_option( 'master_user' );
915    }
916
917    /**
918     * Checks to see if the connection owner of the site is missing.
919     *
920     * @return bool
921     */
922    public function is_missing_connection_owner() {
923        $connection_owner = $this->get_connection_owner_id();
924        if ( ! get_user_by( 'id', $connection_owner ) ) {
925            return true;
926        }
927
928        return false;
929    }
930
931    /**
932     * Returns true if the user with the specified identifier is connected to
933     * WordPress.com.
934     *
935     * @param int $user_id the user identifier. Default is the current user.
936     * @return bool Boolean is the user connected?
937     */
938    public function is_user_connected( $user_id = false ) {
939        $user_id = false === $user_id ? get_current_user_id() : absint( $user_id );
940        if ( ! $user_id ) {
941            return false;
942        }
943
944        return (bool) $this->get_tokens()->get_access_token( $user_id );
945    }
946
947    /**
948     * Returns the local user ID of the connection owner.
949     *
950     * @return bool|int Returns the ID of the connection owner or False if no connection owner found.
951     */
952    public function get_connection_owner_id() {
953        // Check if the memoized value is available.
954        if ( null === self::$connection_owner_id ) {
955            $owner                     = $this->get_connection_owner();
956            self::$connection_owner_id = $owner instanceof \WP_User ? $owner->ID : 0;
957        }
958
959        // If the ID is set to 0, there's no valid connection owner.
960        return self::$connection_owner_id > 0 ? self::$connection_owner_id : false;
961    }
962
963    /**
964     * Get the wpcom user data of the current|specified connected user.
965     *
966     * Fetches the data from the WordPress.com `jetpack-wpcom-user-data` REST endpoint
967     * with a signed request as the connected user. Routing this through
968     * Client::remote_request() (rather than the legacy `wpcom.getUser` XML-RPC method)
969     * ensures any connection errors are captured by the Error_Handler.
970     *
971     * @since 8.8.1 Fetch the data over REST instead of the `wpcom.getUser` XML-RPC method.
972     *
973     * @param int|null $user_id the user identifier.
974     * @return bool|array An array with the WPCOM user data on success, false otherwise.
975     */
976    public function get_connected_user_data( $user_id = null ) {
977        if ( ! $user_id ) {
978            $user_id = get_current_user_id();
979        }
980
981        // Check if the user is connected and return false otherwise.
982        if ( ! $this->is_user_connected( $user_id ) ) {
983            return false;
984        }
985
986        $transient_key    = "jetpack_connected_user_data_$user_id";
987        $cached_user_data = get_transient( $transient_key );
988
989        if ( 'error' === $cached_user_data ) {
990            return false;
991        }
992
993        if ( $cached_user_data ) {
994            return $cached_user_data;
995        }
996
997        $blog_id = (int) \Jetpack_Options::get_option( 'id' );
998
999        // Build a signed request as the connected user. We can't use
1000        // Client::wpcom_json_api_request_as_user() because it always signs as the
1001        // current user, whereas this method may be called for an arbitrary $user_id.
1002        $args            = Client::validate_args_for_wpcom_json_api_request(
1003            "/sites/{$blog_id}/jetpack-wpcom-user-data",
1004            '2',
1005            array( 'method' => 'GET' )
1006        );
1007        $args['user_id'] = $user_id;
1008
1009        $response = Client::remote_request( $args );
1010
1011        if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
1012            // Cache errors briefly so a failing remote request doesn't result in
1013            // a blocking request on every call, e.g. on each admin page
1014            // load via Initial_State::set_connection_script_data().
1015            set_transient( $transient_key, 'error', 5 * MINUTE_IN_SECONDS );
1016
1017            return false;
1018        }
1019
1020        $user_data = json_decode( wp_remote_retrieve_body( $response ), true );
1021
1022        if ( ! is_array( $user_data ) || empty( $user_data ) ) {
1023            set_transient( $transient_key, 'error', 5 * MINUTE_IN_SECONDS );
1024
1025            return false;
1026        }
1027
1028        set_transient( $transient_key, $user_data, DAY_IN_SECONDS );
1029
1030        return $user_data;
1031    }
1032
1033    /**
1034     * Returns the WordPress.com user ID of a connected user.
1035     *
1036     * Answers only for a user who currently holds a token: the binding outlives any one token, so
1037     * connectedness is checked here rather than inferred from a row existing. Resolving an unbound
1038     * user costs a blocking request to WordPress.com, so this is not safe to call per row.
1039     *
1040     * @since 9.2.0
1041     *
1042     * @param int|false $user_id The local user identifier. Default is the current user.
1043     * @return int The WordPress.com user ID, or 0 if it could not be determined.
1044     */
1045    public function resolve_wpcom_user_id( $user_id = false ) {
1046        $user_id = $user_id ? absint( $user_id ) : get_current_user_id();
1047
1048        // The binding outlives the token, unlike the transient behind `get_connected_user_data()`,
1049        // so connectedness is checked here rather than left to the lookup below.
1050        if ( ! $user_id || ! $this->is_user_connected( $user_id ) ) {
1051            return 0;
1052        }
1053
1054        $bound = Utils::get_wpcom_user_id( $user_id );
1055
1056        if ( $bound ) {
1057            return $bound;
1058        }
1059
1060        $user_data = $this->get_connected_user_data( $user_id );
1061
1062        // Callers must read 0 as "unknown", never as "no match": a failed lookup lands here too.
1063        if ( empty( $user_data['ID'] ) ) {
1064            return 0;
1065        }
1066
1067        Utils::set_wpcom_user_id( $user_id, (int) $user_data['ID'] );
1068
1069        return (int) $user_data['ID'];
1070    }
1071
1072    /**
1073     * Unbind the WordPress.com user ID of any user whose token is new.
1074     *
1075     * Every path that changes a user's token writes the `user_tokens` option, so this covers
1076     * authorize, remote connect and the REST endpoint alike. A token that is added or replaced can
1077     * name a different WordPress.com account, so any binding it would answer with is unverified. A
1078     * token merely removed leaves the binding correct, and other subsystems store their own meaning
1079     * in the same meta, so removals are left alone.
1080     *
1081     * @internal Hooked on `pre_update_jetpack_option_user_tokens`, which fires before the write.
1082     * @since 9.2.0
1083     *
1084     * @param string $name  The option name.
1085     * @param mixed  $value The tokens about to be written.
1086     */
1087    public function unbind_wpcom_user_ids_for_new_tokens( $name, $value ) {
1088        if ( ! is_array( $value ) ) {
1089            return;
1090        }
1091
1092        // A site disconnect deletes the option outright, so the first write back has nothing to
1093        // diff against â€” treat that as every token being new rather than skipping the check.
1094        $previous = \Jetpack_Options::get_option( 'user_tokens' );
1095        $previous = is_array( $previous ) ? $previous : array();
1096
1097        // Iterating the incoming tokens covers a token being added as well as replaced, and skips
1098        // removal for free: a user absent from the new set is never visited.
1099        foreach ( $value as $user_id => $token ) {
1100            if ( ( $previous[ $user_id ] ?? null ) !== $token ) {
1101                Utils::delete_wpcom_user_id( $user_id );
1102            }
1103        }
1104    }
1105
1106    /**
1107     * Drop the cached WordPress.com site record.
1108     *
1109     * A caller that fetched the record by another route holds something newer than the cache can,
1110     * and `jetpack_site_data_fetched` fires on a cached read too. The cached copy has to go, or it
1111     * keeps announcing the older record and undoes what that caller stored.
1112     *
1113     * @since 9.0.0
1114     *
1115     * @return void
1116     */
1117    public static function delete_cached_site_data() {
1118        $site_id = \Jetpack_Options::get_option( 'id' );
1119
1120        if ( $site_id ) {
1121            delete_transient( self::SITE_DATA_TRANSIENT_PREFIX . $site_id );
1122        }
1123    }
1124
1125    /**
1126     * Fetch the site's own record from the WordPress.com `/sites/%d` endpoint.
1127     *
1128     * The result is cached briefly. Every plugin that bundles this package serves this route, and
1129     * the Jetpack dashboard requests it on mount, so an uncached read means a blocking round trip
1130     * per render.
1131     *
1132     * @since 8.10.0
1133     *
1134     * @return object|WP_Error The decoded site record, or an error describing the failure.
1135     */
1136    public function get_connected_site_data() {
1137        $site_id = \Jetpack_Options::get_option( 'id' );
1138
1139        if ( ! $site_id ) {
1140            return new WP_Error( 'site_id_missing', '', array( 'api_error_code' => 'site_id_missing' ) );
1141        }
1142
1143        $sandbox_secret = null;
1144
1145        // An array cookie (`store_sandbox[]=`) carries no secret to send, and `filter_var()` turns
1146        // it into `false`.
1147        if ( isset( $_COOKIE['store_sandbox'] ) && is_string( $_COOKIE['store_sandbox'] ) ) {
1148            // Keep only RFC 6265 cookie-octets so the value cannot break out of the Cookie header.
1149            $sandbox_secret = preg_replace( '/[^\x21-\x7E]|[";,\\\\]/', '', filter_var( wp_unslash( $_COOKIE['store_sandbox'] ) ) );
1150
1151            // An empty cookie, or one the sanitizer strips to nothing, is not a sandbox secret.
1152            // Counting it as one opts the request out of the cache with no sandbox to reach.
1153            if ( '' === $sandbox_secret ) {
1154                $sandbox_secret = null;
1155            }
1156        }
1157
1158        // A sandboxed request must neither read the shared cache nor seed it with sandbox data.
1159        $sandboxed     = null !== $sandbox_secret;
1160        $transient_key = self::SITE_DATA_TRANSIENT_PREFIX . $site_id;
1161
1162        // WordPress.com itself requests this route right after a purchase, for the side effect of
1163        // the `jetpack_site_data_fetched` consumers storing the new plan. Those requests arrive
1164        // signed with a connection token, which no browser request carries. A cached read would
1165        // hand that refresh the pre-purchase record and turn it into a no-op, so a signed request
1166        // always reads from WordPress.com and replaces the cache with the record it fetched.
1167        $signed = Rest_Authentication::is_signed_with_blog_token() || Rest_Authentication::is_signed_with_user_token();
1168
1169        $result = ( $sandboxed || $signed ) ? false : get_transient( $transient_key );
1170
1171        // Only the array shape stored below can be served. A `pre_transient_*` filter or a damaged
1172        // object cache entry can hand back anything, and a non-array would throw on
1173        // `$result['body']` for every request until the entry expired, so it reads as a miss.
1174        if ( ! is_array( $result ) ) {
1175            $result = $this->fetch_connected_site_data( $site_id, $sandbox_secret );
1176
1177            // A signed read that failed must not replace a still-usable cached record with the failure.
1178            if ( ! $sandboxed && ! ( $signed && isset( $result['error'] ) ) ) {
1179                // Failures expire sooner so an outage recovers without waiting out a full success window.
1180                set_transient(
1181                    $transient_key,
1182                    $result,
1183                    isset( $result['error'] ) ? 2 * MINUTE_IN_SECONDS : 5 * MINUTE_IN_SECONDS
1184                );
1185            }
1186        }
1187
1188        if ( isset( $result['error'] ) ) {
1189            return new WP_Error( 'site_data_fetch_failed', '', $result['error'] );
1190        }
1191
1192        /**
1193         * Fires after the site record was served, whether it was fetched or read from the cache.
1194         *
1195         * Consumers that cache anything derived from the record, such as the current plan,
1196         * can refresh it here.
1197         *
1198         * This fires on a cached read too, so a consumer stays in step with every request that
1199         * serves the record rather than only the ones that reached WordPress.com.
1200         *
1201         * The record is passed as an array rather than the object this method returns, so that a
1202         * listener cannot mutate the instance that becomes the REST response.
1203         *
1204         * @since 9.0.0
1205         *
1206         * @param array $record The decoded site record from the WordPress.com `/sites/%d` endpoint.
1207         */
1208        do_action( 'jetpack_site_data_fetched', json_decode( $result['body'], true ) );
1209
1210        return json_decode( $result['body'] );
1211    }
1212
1213    /**
1214     * Request the site record from WordPress.com.
1215     *
1216     * Returns a cacheable array rather than the decoded record so that both outcomes survive a
1217     * round trip through a transient.
1218     *
1219     * @since 9.0.0
1220     *
1221     * @param int         $site_id        The WordPress.com blog ID.
1222     * @param string|null $sandbox_secret Sanitized store sandbox cookie value, or null when not sandboxed.
1223     * @return array Either `array( 'body' => string )` or `array( 'error' => array )`.
1224     */
1225    private function fetch_connected_site_data( $site_id, $sandbox_secret ) {
1226        $args = array( 'headers' => array() );
1227
1228        // Allow use a store sandbox. Internal ref: PCYsg-IA-p2.
1229        if ( null !== $sandbox_secret ) {
1230            $args['headers']['Cookie'] = "store_sandbox=$sandbox_secret;";
1231        }
1232
1233        $response = Client::wpcom_json_api_request_as_blog( sprintf( '/sites/%d', $site_id ) . '?force=wpcom', '1.1', $args );
1234        $body     = wp_remote_retrieve_body( $response );
1235        $data     = $body ? json_decode( $body ) : null;
1236
1237        if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
1238            $error_info = array(
1239                'api_error_code' => null,
1240                'api_http_code'  => wp_remote_retrieve_response_code( $response ),
1241            );
1242
1243            if ( is_wp_error( $response ) ) {
1244                $error_info['api_error_code'] = $response->get_error_code() ? wp_strip_all_tags( $response->get_error_code() ) : null;
1245            } elseif ( $data && ! empty( $data->error ) ) {
1246                $error_info['api_error_code'] = is_string( $data->error ) ? wp_strip_all_tags( $data->error ) : null;
1247            }
1248
1249            return array( 'error' => $error_info );
1250        }
1251
1252        if ( ! is_object( $data ) ) {
1253            return array(
1254                'error' => array(
1255                    'api_error_code' => 'invalid_body',
1256                    'api_http_code'  => 200,
1257                ),
1258            );
1259        }
1260
1261        return array( 'body' => $body );
1262    }
1263
1264    /**
1265     * Returns a user object of the connection owner.
1266     *
1267     * @return WP_User|false False if no connection owner found.
1268     */
1269    public function get_connection_owner() {
1270        $user_id = \Jetpack_Options::get_option( 'master_user' );
1271        if ( ! $user_id ) {
1272            return false;
1273        }
1274
1275        // Make sure user is connected.
1276        $user_token = $this->get_tokens()->get_access_token( $user_id );
1277
1278        $connection_owner = false;
1279
1280        if ( $user_token && is_object( $user_token ) && isset( $user_token->external_user_id ) ) {
1281            $connection_owner = get_userdata( $user_token->external_user_id );
1282        }
1283
1284        // Reporting is best-effort and must never fatal a request running mid-plugin-update:
1285        // skip it when the already-loaded Error_Handler is a stale version predating the factory.
1286        if ( $connection_owner === false && method_exists( Error_Handler::class, 'build_connection_wp_error' ) ) {
1287            Error_Handler::get_instance()->report_error(
1288                Error_Handler::build_connection_wp_error(
1289                    'invalid_connection_owner',
1290                    'Invalid connection owner',
1291                    array( 'token' => '' ),
1292                    'local_state', // Error_Handler::ERROR_TYPE_LOCAL_STATE.
1293                    '', // Local-state errors describe the site's database, not a request, so they have no direction.
1294                    array(
1295                        'user_id'        => $user_id,
1296                        'has_user_token' => (bool) $user_token,
1297                    )
1298                ),
1299                false,
1300                true
1301            );
1302        }
1303
1304        return $connection_owner;
1305    }
1306
1307    /**
1308     * Returns true if the provided user is the Jetpack connection owner.
1309     * If user ID is not specified, the current user will be used.
1310     *
1311     * @param int|bool $user_id the user identifier. False for current user.
1312     * @return bool True the user the connection owner, false otherwise.
1313     */
1314    public function is_connection_owner( $user_id = false ) {
1315        if ( ! $user_id ) {
1316            $user_id = get_current_user_id();
1317        }
1318
1319        return ( (int) $user_id ) === $this->get_connection_owner_id();
1320    }
1321
1322    /**
1323     * Determines whether the connection ownership can be transferred to another user.
1324     *
1325     * The default Jetpack connection uses a transferable ownership model; a set protected owner
1326     * anchor locks it. This is the single chokepoint used both when deciding which
1327     * connection-error CTA to surface and when performing an ownership change.
1328     *
1329     * @since 8.8.0
1330     * @since 9.3.0 A locked protected owner anchor makes ownership non-transferable.
1331     * @since 9.11.0 The `jetpack_connection_ownership_transferable` filter is deprecated.
1332     *
1333     * @return bool True if ownership can be transferred, false if it is locked.
1334     */
1335    public function is_ownership_transferable() {
1336        // Keyed on the anchor, never on has_protected_owner(): an owner who does not match the
1337        // anchor is exactly when ownership must stay locked.
1338        if ( Protected_Owner::is_locked() ) {
1339            return false;
1340        }
1341
1342        /**
1343         * Filters whether the Jetpack connection ownership can be transferred.
1344         *
1345         * Return `false` to lock ownership on a site without a protected owner anchor. The
1346         * anchored owner may still transfer ownership despite this deprecated filter.
1347         *
1348         * @since 8.8.0
1349         * @deprecated 9.11.0 The protected owner anchor is the only supported ownership lock.
1350         *
1351         * @param bool $transferable Whether ownership can be transferred. Default true.
1352         */
1353        return (bool) apply_filters_deprecated( 'jetpack_connection_ownership_transferable', array( true ), 'package-9.11.0', '', __( 'The protected owner anchor is the only ownership lock.', 'jetpack-connection' ) );
1354    }
1355
1356    /**
1357     * Whether a protected owner is required right now.
1358     *
1359     * Evaluated at the moment of the request, not as a standing declaration: a consumer may
1360     * legitimately answer false while it is installed and active â€” running in test mode, say â€”
1361     * and true only at the lifecycle moment that binds something to the owner's identity.
1362     *
1363     * @since 9.3.0
1364     *
1365     * @return bool True if a protected owner is required at this moment. Default false.
1366     */
1367    public function requires_protected_owner() {
1368        /**
1369         * Filters whether a protected owner is required at this moment.
1370         *
1371         * Return `true` at the point a feature is about to bind to the connection owner's
1372         * identity. Answering false at other times is expected and supported.
1373         *
1374         * @since 9.3.0
1375         *
1376         * @param bool $required Whether a protected owner is required. Default false.
1377         */
1378        return (bool) apply_filters( 'jetpack_connection_requires_protected_owner', false );
1379    }
1380
1381    /**
1382     * Whether the connection owner is the protected owner the anchor names.
1383     *
1384     * This is the question consumers gate on before binding anything to the owner's identity.
1385     *
1386     * Reads the binding of the current owner rather than searching for whoever holds the anchored
1387     * ID, so a row on any other user cannot affect the answer. Requiring the owner to hold a live
1388     * token on top of that is what keeps a row written by another subsystem from ever satisfying
1389     * this: both halves are load-bearing, and there are tests for each.
1390     *
1391     * @since 9.3.0
1392     *
1393     * @return bool
1394     */
1395    public function has_protected_owner() {
1396        $anchor = Protected_Owner::get_locked();
1397
1398        if ( ! $anchor ) {
1399            return false;
1400        }
1401
1402        $owner_id = $this->get_connection_owner_id();
1403
1404        if ( ! $owner_id ) {
1405            return false;
1406        }
1407
1408        return $this->resolve_wpcom_user_id( $owner_id ) === (int) $anchor['wpcom_user_id'];
1409    }
1410
1411    /**
1412     * Classify why `has_protected_owner()` answered false, and what would change it.
1413     *
1414     * Deliberately inspects only what the gate inspects â€” the anchor and the current connection
1415     * owner â€” so the two can never disagree about the same site. Anything needing a user search or
1416     * a reachability probe is a different question and is not answered here.
1417     *
1418     * `is_current_user_the_po` reads the current user's own stored binding, never a search for
1419     * whoever holds the anchored ID, so it cannot be confused by a second user carrying the same
1420     * meta, and never costs a network call. It is a hint for copy, not a gate.
1421     *
1422     * @since 9.4.0
1423     *
1424     * @return array{status: string, is_current_user_the_po: bool}
1425     */
1426    public function resolve_protected_owner_state() {
1427        $anchor     = Protected_Owner::get_locked();
1428        $current_id = get_current_user_id();
1429
1430        // Only meaningful against an anchor: with none, there is nothing for the user to be.
1431        // Reads the stored binding rather than resolving it, so classifying a state never costs a
1432        // WordPress.com round trip. An unbound user reads as false and gets the generic copy.
1433        $is_current_user_the_po = $anchor
1434            && Utils::get_wpcom_user_id( $current_id ) === (int) $anchor['wpcom_user_id'];
1435
1436        if ( ! $anchor ) {
1437            $roles = new Roles();
1438
1439            if ( ! current_user_can( 'jetpack_connect' ) || ! current_user_can( $roles->translate_role_to_cap( 'administrator' ) ) ) {
1440                // Eligibility is being an admin, not holding the master slot.
1441                $status = self::PO_STATE_NOT_ELIGIBLE;
1442            } elseif ( ! $this->is_user_connected( $current_id ) ) {
1443                // A WordPress.com identity has to exist before it can be confirmed and locked.
1444                $status = self::PO_STATE_NEEDS_CONNECT_TO_ESTABLISH;
1445            } else {
1446                $status = self::PO_STATE_CAN_ESTABLISH;
1447            }
1448        } else {
1449            $owner_id       = $this->get_connection_owner_id();
1450            $owner_wpcom_id = $owner_id ? $this->resolve_wpcom_user_id( $owner_id ) : 0;
1451
1452            if ( ! $owner_wpcom_id ) {
1453                // A zero is "could not determine", never "does not match", so an owner whose
1454                // identity cannot be confirmed is reported as needing to reconnect, not replaced.
1455                $status = self::PO_STATE_NEEDS_OWNER_RECONNECT;
1456            } elseif ( $owner_wpcom_id !== (int) $anchor['wpcom_user_id'] ) {
1457                // Legitimate, not broken: the first admin to connect takes a vacant master slot,
1458                // so an agency can hold it while the protected owner is away.
1459                $status = self::PO_STATE_NEEDS_DIFFERENT_OWNER;
1460            } else {
1461                $status = self::PO_STATE_RE_EVALUATE;
1462            }
1463        }
1464
1465        return array(
1466            'status'                 => $status,
1467            'is_current_user_the_po' => $is_current_user_the_po,
1468        );
1469    }
1470
1471    /**
1472     * Reconcile this site's protected owner against WordPress.com, which is the owner of record.
1473     *
1474     * Runs at connect, when the site has a fresh user token and an answer is cheap, and only once
1475     * something is anchored: a site with no protected owner asks nothing and behaves as it did
1476     * before this existed. One answer settles the rest â€” whether an owner still exists, whether
1477     * the anchor names them, and whether the user connecting is them â€” so the anchor, the binding
1478     * and the master slot are decided together rather than from two calls that could disagree.
1479     *
1480     * Only an answer moves anything. Unreachable, refused, unimplemented and malformed leave the
1481     * anchor exactly as it was: it was confirmed once, and a request that never arrived is no
1482     * evidence against it.
1483     *
1484     * @internal Hooked on `jetpack_user_authorized`.
1485     * @since 9.8.0
1486     *
1487     * @return bool Whether WordPress.com confirmed the anchored identity.
1488     */
1489    public function reconcile_protected_owner() {
1490        $user_id = get_current_user_id();
1491
1492        if ( ! $user_id ) {
1493            return false;
1494        }
1495
1496        $anchor = Protected_Owner::get();
1497
1498        // Nothing anchored is nothing to reconcile, and a site with no protected owner must behave
1499        // exactly as it did before this existed â€” including making no request. Such a site reaches
1500        // an owner through the claim instead, which is where confirming belongs.
1501        if ( ! $anchor ) {
1502            return false;
1503        }
1504
1505        $record = $this->query_protected_owner_record( (int) $anchor['wpcom_user_id'] );
1506
1507        // Silence is not an answer. Unreachable, refused and unimplemented leave the anchor exactly
1508        // as it was: it was confirmed once, and a request that never arrived is no evidence against
1509        // it. Dropping a good lock because WordPress.com had a bad minute costs a merchant their
1510        // payouts until the owner happens to connect again.
1511        if ( ! is_array( $record ) || ! isset( $record['has_owner'] ) ) {
1512            return false;
1513        }
1514
1515        // WordPress.com no longer has an owner of record, so neither does this site. Support
1516        // clearing it at that end is how a wrongly anchored site recovers.
1517        if ( ! $record['has_owner'] ) {
1518            Protected_Owner::clear();
1519
1520            return false;
1521        }
1522
1523        $caller_wpcom_user_id = (int) ( $record['caller_wpcom_user_id'] ?? 0 );
1524
1525        // The identity is disclosed only to the owner it names, so this is the one branch that can
1526        // learn it â€” and what it settles is which account the anchor should name, since
1527        // WordPress.com may have moved the owner since this site last asked.
1528        if ( ! empty( $record['is_caller'] ) ) {
1529            return $this->adopt_protected_owner( $user_id, $caller_wpcom_user_id, $anchor );
1530        }
1531
1532        // Connecting cleared this, and it is the caller's own identity rather than the owner's, so
1533        // it is written whoever they are. Nothing is anchored on this path, so an early write
1534        // cannot strand a half-finished lock.
1535        if ( $caller_wpcom_user_id ) {
1536            Utils::set_wpcom_user_id( $user_id, $caller_wpcom_user_id );
1537        }
1538
1539        // Somebody else is connecting. WordPress.com confirms the anchored identity rather than
1540        // naming the owner, so the answer is the same whoever asks.
1541        if ( empty( $record['matches'] ) ) {
1542            Protected_Owner::clear();
1543
1544            return false;
1545        }
1546
1547        return true;
1548    }
1549
1550    /**
1551     * Take WordPress.com's word that the connecting user owns this site.
1552     *
1553     * @since 9.8.0
1554     *
1555     * @param int        $user_id       The connecting local user.
1556     * @param int        $wpcom_user_id The connecting user's WordPress.com identity, which this
1557     *                                  branch has just been told is the owner of record.
1558     * @param array|null $anchor        What this site has anchored, if anything.
1559     * @return bool Whether the anchor now names that identity.
1560     */
1561    private function adopt_protected_owner( $user_id, $wpcom_user_id, $anchor ) {
1562        // An owner without an identity is a malformed answer, and trusting it would lock the site
1563        // to nobody.
1564        if ( ! $wpcom_user_id ) {
1565            return false;
1566        }
1567
1568        // Anchored before the binding, so a failed write leaves nothing behind for a later
1569        // connection to build on. Re-pointing only moves the cached local ID, so it is right only
1570        // while the anchored identity is the one WordPress.com just confirmed.
1571        if ( $anchor && (int) $anchor['wpcom_user_id'] === $wpcom_user_id ) {
1572            Protected_Owner::repoint( $user_id );
1573        } elseif ( ! Protected_Owner::set( $wpcom_user_id, $user_id ) ) {
1574            return false;
1575        }
1576
1577        // Connecting clears the binding, so this writes back the one the answer just confirmed.
1578        Utils::set_wpcom_user_id( $user_id, $wpcom_user_id );
1579
1580        // Eligibility for the master slot is being an administrator here, which the owner of record
1581        // need not be.
1582        if ( user_can( $user_id, ( new Roles() )->translate_role_to_cap( 'administrator' ) )
1583            && (int) \Jetpack_Options::get_option( 'master_user' ) !== $user_id ) {
1584            \Jetpack_Options::update_option( 'master_user', $user_id );
1585        }
1586
1587        return true;
1588    }
1589
1590    /**
1591     * Ask WordPress.com whether it still holds the anchored identity as this site's owner.
1592     *
1593     * Split from `reconcile_protected_owner()` so the decision it drives can be exercised without a
1594     * network, which is the half worth testing: every branch of it changes whether a site gates a
1595     * live feature. The anchored ID is sent so the answer confirms rather than discloses.
1596     *
1597     * @since 9.8.0
1598     *
1599     * @param int $anchored_wpcom_user_id The WordPress.com identity this site has anchored.
1600     * @return array|null The record, or null when WordPress.com could not answer.
1601     */
1602    protected function query_protected_owner_record( $anchored_wpcom_user_id ) {
1603        return $this->request_protected_owner_record(
1604            '/reconcile',
1605            array( 'anchored_wpcom_user_id' => (int) $anchored_wpcom_user_id )
1606        );
1607    }
1608
1609    /**
1610     * Claim this site's protected ownership for the current user with WordPress.com.
1611     *
1612     * Split from `set_protected_owner()` so the decision it drives can be exercised without a
1613     * network. The identity travels in the signature rather than the payload, so nothing is sent.
1614     *
1615     * @since 9.8.0
1616     *
1617     * @return array|null The record, or null when WordPress.com could not answer.
1618     */
1619    protected function assert_protected_owner_record() {
1620        return $this->request_protected_owner_record();
1621    }
1622
1623    /**
1624     * Give up this site's protected ownership with WordPress.com.
1625     *
1626     * Split from `release_protected_owner()` so the decision it drives can be exercised without a
1627     * network. The identity travels in the signature rather than the payload, so WordPress.com
1628     * decides whether the caller is the owner it holds.
1629     *
1630     * @since 9.9.0
1631     *
1632     * @return array|null The record, or null when WordPress.com could not answer.
1633     */
1634    protected function relinquish_protected_owner_record() {
1635        return $this->request_protected_owner_record( '/release' );
1636    }
1637
1638    /**
1639     * Call this site's protected-owner resource on WordPress.com, signed as the current user.
1640     *
1641     * @since 9.8.1
1642     *
1643     * @param string     $route The route below the resource, empty for the resource itself.
1644     * @param array|null $body  The request body, or null to send none.
1645     * @return array|null The record, or null when WordPress.com could not answer.
1646     */
1647    private function request_protected_owner_record( $route = '', $body = null ) {
1648        $path = sprintf(
1649            '/sites/%d/jetpack-protected-owner%s',
1650            (int) \Jetpack_Options::get_option( 'id' ),
1651            $route
1652        );
1653
1654        $response = Client::wpcom_json_api_request_as_user( $path, '2', array( 'method' => 'POST' ), $body );
1655
1656        // Anything but a 200 is silence rather than an answer: unreachable, refused, or a
1657        // WordPress.com that does not implement the route. Every caller fails closed on null.
1658        if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
1659            return null;
1660        }
1661
1662        $record = json_decode( wp_remote_retrieve_body( $response ), true );
1663
1664        return is_array( $record ) ? $record : null;
1665    }
1666
1667    /**
1668     * Record a user as the protected owner and promote them to connection owner.
1669     *
1670     * Gated on `jetpack_connect` rather than on a role: a host can narrow that capability and
1671     * multisite does. It is false while the package is unconfigured, so a caller that has not
1672     * registered the connection's capabilities is refused rather than trusted.
1673     *
1674     * @since 9.3.0
1675     * @since 9.6.0 No longer takes how the owner was confirmed.
1676     * @since 9.8.0 WordPress.com records the owner before anything is anchored here.
1677     *
1678     * @param int $user_id The local user to anchor.
1679     * @return true|WP_Error True on success, WP_Error otherwise.
1680     */
1681    public function set_protected_owner( $user_id ) {
1682        // Authorization precedes validation, so an unauthorized caller cannot use the argument
1683        // errors below to learn which users are administrators or hold a token.
1684        if ( ! current_user_can( 'jetpack_connect' ) ) {
1685            return new WP_Error(
1686                'protected_owner_forbidden',
1687                __( 'You do not have permission to manage the protected owner.', 'jetpack-connection' ),
1688                array( 'status' => 403 )
1689            );
1690        }
1691
1692        $user_id = absint( $user_id );
1693        $roles   = new Roles();
1694
1695        if ( ! user_can( $user_id, $roles->translate_role_to_cap( 'administrator' ) ) ) {
1696            return new WP_Error(
1697                'protected_owner_not_admin',
1698                __( 'The protected owner must be an administrator.', 'jetpack-connection' ),
1699                array( 'status' => 400 )
1700            );
1701        }
1702
1703        // The claim is signed as the current user, so it can only ever anchor the current user.
1704        // Anchoring somebody else would be an owner assignment they never agreed to.
1705        if ( $user_id !== get_current_user_id() ) {
1706            return new WP_Error(
1707                'protected_owner_not_self',
1708                __( 'A protected owner can only be recorded by the user confirming it.', 'jetpack-connection' ),
1709                array( 'status' => 400 )
1710            );
1711        }
1712
1713        // A stored binding that already disagrees with the anchor is enough to refuse. WordPress.com
1714        // is still asked when this user has no binding, because that answer is what names them.
1715        if ( $this->local_anchor_names_someone_else( (int) Utils::get_wpcom_user_id( $user_id ) ) ) {
1716            return $this->protected_owner_claimed_by_other();
1717        }
1718
1719        // WordPress.com is asked before anything is written here. It owns the record, so a claim it
1720        // has not accepted must not leave a locked anchor behind on this site.
1721        $record = $this->assert_protected_owner_record();
1722
1723        // Fail closed: unreachable, refused, or a WordPress.com that does not implement the call.
1724        // A site that cannot get an answer must not end up protecting anybody on its own say-so.
1725        if ( ! is_array( $record ) || empty( $record['status'] ) ) {
1726            return new WP_Error(
1727                'protected_owner_unconfirmed',
1728                __( 'Could not reach WordPress.com to confirm the protected owner.', 'jetpack-connection' ),
1729                array( 'status' => 503 )
1730            );
1731        }
1732
1733        // Somebody else already holds this site. Beyond support there is no way past this, which is
1734        // the point: an owner that could be overwritten by the next claimant protects nobody.
1735        if ( 'locked_to_other' === $record['status'] ) {
1736            return $this->protected_owner_claimed_by_other();
1737        }
1738
1739        // Only an accepted claim is anchored: any other verdict is refused, even one carrying an ID.
1740        if ( ! in_array( $record['status'], array( 'recorded', 'already_yours' ), true ) || empty( $record['wpcom_user_id'] ) ) {
1741            return new WP_Error(
1742                'protected_owner_not_verified',
1743                __( 'Could not confirm the protected owner with WordPress.com.', 'jetpack-connection' ),
1744                array( 'status' => 400 )
1745            );
1746        }
1747
1748        // A `recorded` answer must not replace an anchor that already names a different account.
1749        if ( $this->local_anchor_names_someone_else( (int) $record['wpcom_user_id'] ) ) {
1750            return $this->protected_owner_claimed_by_other();
1751        }
1752
1753        // Store the binding the anchor will be compared against, so the gate reads local state from
1754        // here on. Routed through the deduping writer, which clears the ID off any previous holder.
1755        Utils::set_wpcom_user_id( $user_id, (int) $record['wpcom_user_id'] );
1756
1757        if ( ! Protected_Owner::set( (int) $record['wpcom_user_id'], $user_id ) ) {
1758            return new WP_Error(
1759                'protected_owner_not_stored',
1760                __( 'Could not store the protected owner.', 'jetpack-connection' ),
1761                array( 'status' => 500 )
1762            );
1763        }
1764
1765        // Written directly rather than through update_connection_owner(): that round-trips to
1766        // WordPress.com first, and its ownership-change guard will refuse the anchor just set here.
1767        \Jetpack_Options::update_option( 'master_user', $user_id );
1768
1769        return true;
1770    }
1771
1772    /**
1773     * Release the protected owner, leaving ownership open to any connected administrator.
1774     *
1775     * WordPress.com holds the record, so it is cleared there first. An anchor dropped only here
1776     * would leave WordPress.com refusing every later claim as `locked_to_other`, locking the site
1777     * to nobody rather than unlocking it.
1778     *
1779     * Leaves `master_user` alone: releasing the lock does not change who the owner is.
1780     *
1781     * @since 9.9.0
1782     *
1783     * @return true|WP_Error True on success, WP_Error otherwise.
1784     */
1785    public function release_protected_owner() {
1786        // Authorization precedes everything else, so an unauthorized caller cannot use the
1787        // refusals below to learn whether this site is protected or by whom.
1788        if ( ! current_user_can( 'jetpack_connect' ) ) {
1789            return new WP_Error(
1790                'protected_owner_forbidden',
1791                __( 'You do not have permission to manage the protected owner.', 'jetpack-connection' ),
1792                array( 'status' => 403 )
1793            );
1794        }
1795
1796        // Nothing anchored is already released, so repeating the call is not an error. It can also
1797        // be an anchor lost while WordPress.com kept its record, which this site cannot tell apart
1798        // and cannot recover from alone â€” hence a warning rather than silence.
1799        $anchor = Protected_Owner::get_locked();
1800
1801        if ( ! $anchor ) {
1802            wp_trigger_error(
1803                __METHOD__,
1804                'Released with no protected owner on record. If WordPress.com still holds one, this site can no longer claim it back.',
1805                E_USER_WARNING
1806            );
1807
1808            return true;
1809        }
1810
1811        // A local hint that spares an obvious refusal a round trip. WordPress.com is asked anyway
1812        // whenever this passes, and its answer is the one that decides.
1813        if ( Utils::get_wpcom_user_id( get_current_user_id() ) !== (int) $anchor['wpcom_user_id'] ) {
1814            return $this->protected_owner_release_refused();
1815        }
1816
1817        $record = $this->relinquish_protected_owner_record();
1818
1819        // Fail closed: unreachable, refused, or a WordPress.com that does not implement the call.
1820        // Clearing on silence would unlock a site WordPress.com still holds.
1821        if ( ! is_array( $record ) || empty( $record['status'] ) ) {
1822            return new WP_Error(
1823                'protected_owner_unreleased',
1824                __( 'Could not reach WordPress.com to release the protected owner.', 'jetpack-connection' ),
1825                array( 'status' => 503 )
1826            );
1827        }
1828
1829        if ( 'not_owner' === $record['status'] ) {
1830            return $this->protected_owner_release_refused();
1831        }
1832
1833        // `no_owner` is WordPress.com reporting it holds nothing to release, which is the state
1834        // this call asks for, so the stale anchor here clears alongside an accepted release.
1835        if ( ! in_array( $record['status'], array( 'released', 'no_owner' ), true ) ) {
1836            return new WP_Error(
1837                'protected_owner_not_released',
1838                __( 'Could not release the protected owner with WordPress.com.', 'jetpack-connection' ),
1839                array( 'status' => 400 )
1840            );
1841        }
1842
1843        $cleared = $this->clear_protected_owner();
1844
1845        // WordPress.com has already let go, so a local delete that failed is unfinished cleanup
1846        // rather than a release that did not happen. Retrying is what fixes it: reconcile only
1847        // runs when somebody authorizes, and WordPress.com now answers this call with `no_owner`.
1848        if ( is_wp_error( $cleared ) && 'protected_owner_not_cleared' === $cleared->get_error_code() ) {
1849            return new WP_Error(
1850                'protected_owner_not_cleared',
1851                __( 'Ownership was released with WordPress.com, but this site could not finish clearing it. Try again.', 'jetpack-connection' ),
1852                array( 'status' => 500 )
1853            );
1854        }
1855
1856        return $cleared;
1857    }
1858
1859    /**
1860     * The refusal for a caller who is not the owner WordPress.com holds.
1861     *
1862     * @since 9.9.0
1863     *
1864     * @return WP_Error
1865     */
1866    private function protected_owner_release_refused() {
1867        return new WP_Error(
1868            'protected_owner_not_owner',
1869            __( 'Only the confirmed owner can release ownership of this site.', 'jetpack-connection' ),
1870            array( 'status' => 403 )
1871        );
1872    }
1873
1874    /**
1875     * Whether a WordPress.com user id would replace the stored anchor.
1876     *
1877     * Zero means this user is not named yet, so it is not a conflict.
1878     *
1879     * @since 9.9.0
1880     *
1881     * @param int $wpcom_user_id WordPress.com user the claim would anchor.
1882     * @return bool
1883     */
1884    private function local_anchor_names_someone_else( $wpcom_user_id ) {
1885        $anchor = Protected_Owner::get_locked();
1886
1887        return $anchor && $wpcom_user_id && (int) $anchor['wpcom_user_id'] !== (int) $wpcom_user_id;
1888    }
1889
1890    /**
1891     * The support path for a site a different account already protects.
1892     *
1893     * @since 9.9.0
1894     *
1895     * @return WP_Error
1896     */
1897    private function protected_owner_claimed_by_other() {
1898        return new WP_Error(
1899            'protected_owner_claimed_by_other',
1900            __( 'This site is already protected by a different WordPress.com account. Contact support.', 'jetpack-connection' ),
1901            array( 'status' => 409 )
1902        );
1903    }
1904
1905    /**
1906     * Drop the protected owner anchor, unlocking ownership.
1907     *
1908     * Gated on `jetpack_connect` like establishing one, releasing a lock being the more
1909     * consequential half. The `@internal` tag is documentation; the capability is enforcement.
1910     *
1911     * @internal Recovery and support flows only. Consumers must not call this.
1912     * @since 9.3.0
1913     *
1914     * @return true|WP_Error True once no anchor is set, WP_Error otherwise.
1915     */
1916    public function clear_protected_owner() {
1917        if ( ! current_user_can( 'jetpack_connect' ) ) {
1918            return new WP_Error(
1919                'protected_owner_forbidden',
1920                __( 'You do not have permission to manage the protected owner.', 'jetpack-connection' ),
1921                array( 'status' => 403 )
1922            );
1923        }
1924
1925        Protected_Owner::clear();
1926
1927        // Asked of the outcome rather than of `delete_option()`, which also reports false for an
1928        // anchor that was already absent â€” the state the caller asked for.
1929        if ( Protected_Owner::get() ) {
1930            return new WP_Error(
1931                'protected_owner_not_cleared',
1932                __( 'Could not clear the protected owner.', 'jetpack-connection' ),
1933                array( 'status' => 500 )
1934            );
1935        }
1936
1937        return true;
1938    }
1939
1940    /**
1941     * Connects the user with a specified ID to a WordPress.com user using the
1942     * remote login flow.
1943     *
1944     * @access public
1945     *
1946     * @param int|null    $user_id (optional) the user identifier, defaults to current user.
1947     * @param string|null $redirect_url the URL to redirect the user to for processing, defaults to
1948     *                             admin_url().
1949     * @return WP_Error only in case of a failed user lookup.
1950     */
1951    public function connect_user( $user_id = null, $redirect_url = null ) {
1952        $user = null;
1953        if ( null === $user_id ) {
1954            $user = wp_get_current_user();
1955        } else {
1956            $user = get_user_by( 'ID', $user_id );
1957        }
1958
1959        if ( empty( $user ) ) {
1960            return new \WP_Error( 'user_not_found', 'Attempting to connect a non-existent user.' );
1961        }
1962
1963        if ( null === $redirect_url ) {
1964            $redirect_url = admin_url();
1965        }
1966
1967        // Using wp_redirect intentionally because we're redirecting outside.
1968        wp_redirect( $this->get_authorization_url( $user, $redirect_url ) ); // phpcs:ignore WordPress.Security.SafeRedirect
1969        exit( 0 );
1970    }
1971
1972    /**
1973     * Force user disconnect.
1974     *
1975     * @param int  $user_id Local (external) user ID.
1976     * @param bool $disconnect_all_users Whether to disconnect all users before disconnecting the primary user.
1977     *
1978     * @return bool
1979     */
1980    public function disconnect_user_force( $user_id, $disconnect_all_users = false ) {
1981        if ( ! (int) $user_id ) {
1982            // Missing user ID.
1983            return false;
1984        }
1985        // If we are disconnecting the primary user we may need to disconnect all other users first
1986        if ( $user_id === $this->get_connection_owner_id() && $disconnect_all_users && ! $this->disconnect_all_users_except_primary() ) {
1987            return false;
1988        }
1989
1990        return $this->disconnect_user( $user_id, true, true );
1991    }
1992
1993    /**
1994     * Disconnects all users except the primary user.
1995     *
1996     * @return bool
1997     */
1998    public function disconnect_all_users_except_primary() {
1999
2000        $all_connected_users = $this->get_connected_users();
2001
2002        foreach ( $all_connected_users as $user ) {
2003            // Skip the primary.
2004            if ( $user->ID === $this->get_connection_owner_id() ) {
2005                continue;
2006            }
2007            $disconnected = $this->disconnect_user( $user->ID, false, true );
2008            // If we fail to disconnect any user, we should not proceed with disconnecting the primary user.
2009            if ( ! $disconnected ) {
2010                return false;
2011            }
2012        }
2013
2014        return true;
2015    }
2016
2017    /**
2018     * Unlinks the current user from the linked WordPress.com user.
2019     *
2020     * @access public
2021     * @static
2022     *
2023     * @todo Refactor to properly load the XMLRPC client independently.
2024     *
2025     * @param int|null $user_id the user identifier.
2026     * @param bool     $can_overwrite_primary_user Allow for the primary user to be disconnected.
2027     * @param bool     $force_disconnect_locally Disconnect user locally even if we were unable to disconnect them from WP.com.
2028     * @return bool Whether the disconnection of the user was successful.
2029     */
2030    public function disconnect_user( $user_id = null, $can_overwrite_primary_user = false, $force_disconnect_locally = false ) {
2031        $user_id         = empty( $user_id ) ? get_current_user_id() : (int) $user_id;
2032        $is_primary_user = Jetpack_Options::get_option( 'master_user' ) === $user_id;
2033
2034        if ( $is_primary_user && ! $can_overwrite_primary_user ) {
2035            return false;
2036        }
2037
2038        if ( in_array( $user_id, self::$disconnected_users, true ) ) {
2039            // The user is already disconnected.
2040            return false;
2041        }
2042
2043        // Attempt to disconnect the user from WordPress.com.
2044        $is_disconnected_from_wpcom = $this->unlink_user_from_wpcom( $user_id );
2045
2046        $is_disconnected_locally = false;
2047        if ( $is_disconnected_from_wpcom || $force_disconnect_locally ) {
2048            // Get the WordPress.com email before disconnecting the user
2049            $wpcom_user_data = $this->get_connected_user_data( $user_id );
2050            $wpcom_email     = $wpcom_user_data['email'] ?? null;
2051
2052            // Disconnect the user locally.
2053            $is_disconnected_locally = $this->get_tokens()->disconnect_user( $user_id );
2054
2055            if ( $is_disconnected_locally ) {
2056                // Delete cached connected user data.
2057                $transient_key = "jetpack_connected_user_data_$user_id";
2058                delete_transient( $transient_key );
2059
2060                // Clean up account mismatch transients for this user
2061                if ( $wpcom_email ) {
2062                    $user_account_status = new User_Account_Status();
2063                    $user_account_status->clean_account_mismatch_transients( $wpcom_email );
2064                }
2065
2066                /**
2067                 * Fires after the current user has been unlinked from WordPress.com.
2068                 *
2069                 * @since 1.7.0
2070                 * @since-jetpack 4.1.0
2071                 *
2072                 * @param int $user_id The current user's ID.
2073                 */
2074                do_action( 'jetpack_unlinked_user', $user_id );
2075
2076                if ( $is_primary_user ) {
2077                    Jetpack_Options::delete_option( 'master_user' );
2078
2079                    // Clear the memoized connection owner ID since it changed
2080                    self::$connection_owner_id = null;
2081                }
2082            }
2083        }
2084
2085        self::$disconnected_users[] = $user_id;
2086
2087        return $is_disconnected_from_wpcom && $is_disconnected_locally;
2088    }
2089
2090    /**
2091     * Request to wpcom for a user to be unlinked from their WordPress.com account
2092     *
2093     * @param int $user_id The user identifier.
2094     *
2095     * @return bool Whether the disconnection of the user was successful.
2096     */
2097    public function unlink_user_from_wpcom( $user_id ) {
2098        // Attempt to disconnect the user from WordPress.com.
2099        $xml = new Jetpack_IXR_Client();
2100
2101        $xml->query( 'jetpack.unlink_user', $user_id );
2102        if ( $xml->isError() ) {
2103            return false;
2104        }
2105
2106        return (bool) $xml->getResponse();
2107    }
2108
2109    /**
2110     * Update the connection owner.
2111     *
2112     * @since 1.29.0
2113     * @since 9.3.0 Refused while ownership is locked.
2114     * @since 9.9.0 The anchored owner passes the lock, and moving the site off them
2115     *                         releases the anchor.
2116     *
2117     * @param int $new_owner_id The ID of the user to become the connection owner.
2118     *
2119     * @return true|WP_Error True if owner successfully changed, WP_Error otherwise.
2120     */
2121    public function update_connection_owner( $new_owner_id ) {
2122        // Answered before the arguments are validated: no candidate is valid while ownership is
2123        // locked, and an argument error would suggest a retry that cannot work.
2124        if ( ! $this->is_ownership_transferable() && ! $this->current_user_may_move_locked_ownership() ) {
2125            return new WP_Error(
2126                'ownership_locked',
2127                __( 'The connection owner is locked on this site.', 'jetpack-connection' ),
2128                array( 'status' => 403 )
2129            );
2130        }
2131
2132        $roles = new Roles();
2133        if ( ! user_can( $new_owner_id, $roles->translate_role_to_cap( 'administrator' ) ) ) {
2134            return new WP_Error(
2135                'new_owner_not_admin',
2136                __( 'New owner is not admin', 'jetpack-connection' ),
2137                array( 'status' => 400 )
2138            );
2139        }
2140
2141        $old_owner_id = $this->get_connection_owner_id();
2142
2143        if ( $old_owner_id === $new_owner_id ) {
2144            return new WP_Error(
2145                'new_owner_is_existing_owner',
2146                __( 'New owner is same as existing owner', 'jetpack-connection' ),
2147                array( 'status' => 400 )
2148            );
2149        }
2150
2151        if ( ! $this->is_user_connected( $new_owner_id ) ) {
2152            return new WP_Error(
2153                'new_owner_not_connected',
2154                __( 'New owner is not connected', 'jetpack-connection' ),
2155                array( 'status' => 400 )
2156            );
2157        }
2158
2159        // Notify WPCOM about the connection owner change.
2160        $owner_updated_wpcom = $this->update_connection_owner_wpcom( $new_owner_id );
2161
2162        if ( $owner_updated_wpcom ) {
2163            // Update the connection owner in Jetpack only if they were successfully updated on WPCOM.
2164            // This will ensure consistency with WPCOM.
2165            \Jetpack_Options::update_option( 'master_user', $new_owner_id );
2166
2167            // Clear the memoized connection owner ID since it changed
2168            self::$connection_owner_id = null;
2169
2170            $this->release_anchor_after_transfer( $new_owner_id, $owner_updated_wpcom );
2171
2172            // Track it.
2173            ( new Tracking() )->record_user_event( 'set_connection_owner_success' );
2174
2175            return true;
2176        }
2177        return new WP_Error(
2178            'error_setting_new_owner',
2179            __( 'Could not confirm new owner.', 'jetpack-connection' ),
2180            array( 'status' => 500 )
2181        );
2182    }
2183
2184    /**
2185     * Whether the current user may move the connection despite a locked anchor.
2186     *
2187     * The anchor protects an identity, so the owner it names is the one person it is not against.
2188     *
2189     * A local hint rather than proof of who that is: the binding is not unique site-wide, and the
2190     * anchored owner is often not the connection owner here â€” taking a site back from an agency is
2191     * the point â€” so there is no stronger identity to check. WordPress.com decides, marking a
2192     * switch `po_signed` only when the signing token belongs to the owner of record.
2193     *
2194     * @since 9.9.0
2195     * @since 9.11.0 No longer refused by the deprecated `jetpack_connection_ownership_transferable` filter.
2196     *
2197     * @return bool
2198     */
2199    private function current_user_may_move_locked_ownership() {
2200        $anchor  = Protected_Owner::get_locked();
2201        $user_id = get_current_user_id();
2202
2203        if ( ! $anchor || ! $user_id ) {
2204            return false;
2205        }
2206
2207        // Both halves, as everywhere else the binding is trusted: it outlives the token, so a
2208        // disconnected user can still carry the anchored ID.
2209        if ( ! $this->is_user_connected( $user_id ) ) {
2210            return false;
2211        }
2212
2213        // This user's own binding, never a search for whoever holds the anchored ID, which would
2214        // hand the site to the first match.
2215        return Utils::get_wpcom_user_id( $user_id ) === (int) $anchor['wpcom_user_id'];
2216    }
2217
2218    /**
2219     * Drop the anchor once the site has left the owner it names.
2220     *
2221     * Do not make this clear more eagerly. An anchor dropped while WordPress.com kept its own
2222     * locks the site to nobody, and `reconcile_protected_owner()` returns before asking when
2223     * there is no local anchor left to repair it with. The reverse mistake costs nothing.
2224     *
2225     * @since 9.9.0
2226     *
2227     * @param int        $new_owner_id The local user who now holds the connection.
2228     * @param true|array $accepted     What WordPress.com answered the switch with: a report of
2229     *                                 what it did where available, otherwise a bare `true`.
2230     */
2231    private function release_anchor_after_transfer( $new_owner_id, $accepted ) {
2232        $anchor = Protected_Owner::get_locked();
2233
2234        if ( ! $anchor ) {
2235            return;
2236        }
2237
2238        // WordPress.com resolves the new owner itself and knows what it kept, so where it reports
2239        // what it did, that report is the whole answer.
2240        if ( is_array( $accepted ) ) {
2241            if ( ! empty( $accepted['released'] ) ) {
2242                Protected_Owner::clear();
2243            }
2244
2245            return;
2246        }
2247
2248        // A bare `true` says only that the switch happened, leaving who the site went to as the
2249        // best guess available. A zero is "could not determine", which covers the owner taking the
2250        // site back â€” the case WordPress.com keeps its record for.
2251        $new_owner_wpcom_id = $this->resolve_wpcom_user_id( $new_owner_id );
2252
2253        if ( $new_owner_wpcom_id && $new_owner_wpcom_id !== (int) $anchor['wpcom_user_id'] ) {
2254            Protected_Owner::clear();
2255        }
2256    }
2257
2258    /**
2259     * Request to WPCOM to update the connection owner.
2260     *
2261     * @since 1.29.0
2262     * @since 9.9.0 Returns what WordPress.com answered rather than casting it, so a
2263     *                         report of what the switch did can be read. Still falsy on failure.
2264     *
2265     * @param int $new_owner_id The ID of the user to become the connection owner.
2266     *
2267     * @return bool|array False if the transfer failed, otherwise what WordPress.com answered:
2268     *                    `true`, or a non-empty report such as `array( 'released' => bool )`.
2269     */
2270    public function update_connection_owner_wpcom( $new_owner_id ) {
2271        // Notify WPCOM about the connection owner change.
2272        $xml = new Jetpack_IXR_Client(
2273            array(
2274                'user_id' => get_current_user_id(),
2275            )
2276        );
2277        $xml->query(
2278            'jetpack.switchBlogOwner',
2279            array(
2280                'new_blog_owner' => $new_owner_id,
2281            )
2282        );
2283        if ( $xml->isError() ) {
2284            return false;
2285        }
2286
2287        $response = $xml->getResponse();
2288
2289        // An array is the switch reporting what it did, and an empty one reports nothing rather
2290        // than refusing â€” a bare `true` by another name. Only a falsy non-array is a refusal.
2291        if ( is_array( $response ) ) {
2292            return empty( $response ) ? true : $response;
2293        }
2294
2295        return (bool) $response;
2296    }
2297
2298    /**
2299     * Returns the requested Jetpack API URL.
2300     *
2301     * @param string $relative_url the relative API path.
2302     * @return string API URL.
2303     */
2304    public function api_url( $relative_url ) {
2305        $api_base    = Constants::get_constant( 'JETPACK__API_BASE' );
2306        $api_version = '/' . Constants::get_constant( 'JETPACK__API_VERSION' ) . '/';
2307
2308        /**
2309         * Filters the API URL that Jetpack uses for server communication.
2310         *
2311         * @since 1.7.0
2312         * @since-jetpack 8.0.0
2313         *
2314         * @param string $url the generated URL.
2315         * @param string $relative_url the relative URL that was passed as an argument.
2316         * @param string $api_base the API base string that is being used.
2317         * @param string $api_version the API version string that is being used.
2318         */
2319        return apply_filters(
2320            'jetpack_api_url',
2321            rtrim( $api_base . $relative_url, '/\\' ) . $api_version,
2322            $relative_url,
2323            $api_base,
2324            $api_version
2325        );
2326    }
2327
2328    /**
2329     * Returns the Jetpack XMLRPC WordPress.com API endpoint URL.
2330     *
2331     * @return string XMLRPC API URL.
2332     */
2333    public function xmlrpc_api_url() {
2334        $base = preg_replace(
2335            '#(https?://[^?/]+)(/?.*)?$#',
2336            '\\1',
2337            Constants::get_constant( 'JETPACK__API_BASE' )
2338        );
2339        return untrailingslashit( $base ) . '/xmlrpc.php';
2340    }
2341
2342    /**
2343     * Attempts Jetpack registration which sets up the site for connection. Should
2344     * remain public because the call to action comes from the current site, not from
2345     * WordPress.com.
2346     *
2347     * Always attempts: the retry gate and the offline mode check live in
2348     * `try_registration()`, so unattended callers must go through that instead.
2349     *
2350     * @param string $api_endpoint (optional) an API endpoint to use, defaults to 'register'.
2351     * @return true|WP_Error The error object.
2352     */
2353    public function register( $api_endpoint = 'register' ) {
2354        $this->last_register_response = null;
2355
2356        $result = $this->do_register( $api_endpoint );
2357
2358        if ( is_wp_error( $result ) ) {
2359            Registration_Failure::record(
2360                $result,
2361                $this->last_register_response,
2362                $this->get_plugin_slug()
2363            );
2364        } else {
2365            Registration_Failure::clear();
2366        }
2367
2368        $this->last_register_response = null;
2369
2370        return $result;
2371    }
2372
2373    /**
2374     * Perform the registration request and store the resulting connection data.
2375     *
2376     * @see Manager::register()
2377     *
2378     * @param string $api_endpoint An API endpoint to use.
2379     * @return true|WP_Error The error object.
2380     */
2381    private function do_register( $api_endpoint ) {
2382        // Clean-up leftover tokens just in-case.
2383        // This fixes an edge case that was preventing users to register when the blog token was missing but
2384        // there were still leftover user tokens present.
2385        $this->delete_all_connection_tokens( true );
2386
2387        add_action( 'pre_update_jetpack_option_register', array( '\\Jetpack_Options', 'delete_option' ) );
2388        $secrets = ( new Secrets() )->generate( 'register', get_current_user_id(), 600 );
2389
2390        if ( false === $secrets ) {
2391            return new WP_Error( 'cannot_save_secrets', __( 'Jetpack experienced an issue trying to save options (cannot_save_secrets). We suggest that you contact your hosting provider, and ask them for help checking that the options table is writable on your site.', 'jetpack-connection' ) );
2392        }
2393
2394        if (
2395            empty( $secrets['secret_1'] ) ||
2396            empty( $secrets['secret_2'] ) ||
2397            empty( $secrets['exp'] )
2398        ) {
2399            return new \WP_Error( 'missing_secrets' );
2400        }
2401
2402        // Better to try (and fail) to set a higher timeout than this system
2403        // supports than to have register fail for more users than it should.
2404        $timeout = $this->set_min_time_limit( 60 ) / 2;
2405
2406        $gmt_offset = get_option( 'gmt_offset' );
2407        if ( ! $gmt_offset ) {
2408            $gmt_offset = 0;
2409        }
2410
2411        $stats_options = get_option( 'stats_options' );
2412        $stats_id      = $stats_options['blog_id'] ?? null;
2413
2414        /* This action is documented in src/class-package-version-tracker.php */
2415        $package_versions = apply_filters( 'jetpack_package_versions', array() );
2416
2417        $active_plugins_using_connection = Plugin_Storage::get_all();
2418
2419        /**
2420         * Filters the request body for additional property addition.
2421         *
2422         * @since 1.7.0
2423         * @since-jetpack 7.7.0
2424         *
2425         * @param array $post_data request data.
2426         * @param Array $token_data token data.
2427         */
2428        $body = apply_filters(
2429            'jetpack_register_request_body',
2430            array_merge(
2431                array(
2432                    'siteurl'                  => Urls::site_url(),
2433                    'home'                     => Urls::home_url(),
2434                    'gmt_offset'               => $gmt_offset,
2435                    'timezone_string'          => (string) get_option( 'timezone_string' ),
2436                    'site_name'                => (string) get_option( 'blogname' ),
2437                    'secret_1'                 => $secrets['secret_1'],
2438                    'secret_2'                 => $secrets['secret_2'],
2439                    'site_lang'                => get_locale(),
2440                    'timeout'                  => $timeout,
2441                    'stats_id'                 => $stats_id,
2442                    'state'                    => get_current_user_id(),
2443                    'site_created'             => $this->get_assumed_site_creation_date(),
2444                    'jetpack_version'          => Constants::get_constant( 'JETPACK__VERSION' ),
2445                    'ABSPATH'                  => Constants::get_constant( 'ABSPATH' ),
2446                    'current_user_email'       => wp_get_current_user()->user_email,
2447                    'connect_plugin'           => $this->get_plugin_slug(),
2448                    'package_versions'         => $package_versions,
2449                    'active_connected_plugins' => $active_plugins_using_connection,
2450                ),
2451                self::$extra_register_params
2452            )
2453        );
2454
2455        $args = array(
2456            'method'  => 'POST',
2457            'body'    => $body,
2458            'headers' => array(
2459                'Accept' => 'application/json',
2460            ),
2461            'timeout' => $timeout,
2462        );
2463
2464        $args['body'] = static::apply_activation_source_to_args( $args['body'] );
2465
2466        // TODO: fix URLs for bad hosts.
2467        $response = Client::_wp_remote_request(
2468            $this->api_url( $api_endpoint ),
2469            $args,
2470            true
2471        );
2472
2473        // Kept so that ::register() can read response headers such as `Retry-After`
2474        // when recording the failure.
2475        $this->last_register_response = $response;
2476
2477        // Make sure the response is valid and does not contain any Jetpack errors.
2478        $registration_details = $this->validate_remote_register_response( $response );
2479
2480        if ( is_wp_error( $registration_details ) ) {
2481            return $registration_details;
2482        } elseif ( ! $registration_details ) {
2483            return new \WP_Error(
2484                'unknown_error',
2485                'Unknown error registering your Jetpack site.',
2486                wp_remote_retrieve_response_code( $response )
2487            );
2488        }
2489
2490        if ( empty( $registration_details->jetpack_secret ) || ! is_string( $registration_details->jetpack_secret ) ) {
2491            return new \WP_Error(
2492                'jetpack_secret',
2493                'Unable to validate registration of your Jetpack site.',
2494                wp_remote_retrieve_response_code( $response )
2495            );
2496        }
2497
2498        if ( isset( $registration_details->jetpack_public ) ) {
2499            $jetpack_public = (int) $registration_details->jetpack_public;
2500        } else {
2501            $jetpack_public = false;
2502        }
2503
2504        Jetpack_Options::update_options(
2505            array(
2506                'id'     => (int) $registration_details->jetpack_id,
2507                'public' => $jetpack_public,
2508            )
2509        );
2510
2511        update_option( Package_Version_Tracker::PACKAGE_VERSION_OPTION, $package_versions );
2512
2513        $this->get_tokens()->update_blog_token( (string) $registration_details->jetpack_secret );
2514
2515        if ( ! Jetpack_Options::get_option( 'id' ) || ! $this->get_tokens()->get_access_token() ) {
2516            return new WP_Error(
2517                'connection_data_save_failed',
2518                'Failed to save connection data in the database'
2519            );
2520        }
2521
2522        $alternate_authorization_url = $registration_details->alternate_authorization_url ?? '';
2523
2524        add_filter(
2525            'jetpack_register_site_rest_response',
2526            function ( $response ) use ( $alternate_authorization_url ) {
2527                $response['alternateAuthorizeUrl'] = $alternate_authorization_url;
2528                return $response;
2529            }
2530        );
2531
2532        /**
2533         * Fires when a site is registered on WordPress.com.
2534         *
2535         * @since 1.7.0
2536         * @since-jetpack 3.7.0
2537         *
2538         * @param int $json->jetpack_id Jetpack Blog ID.
2539         * @param string $json->jetpack_secret Jetpack Blog Token.
2540         * @param int|bool $jetpack_public Is the site public.
2541         */
2542        do_action(
2543            'jetpack_site_registered',
2544            $registration_details->jetpack_id,
2545            $registration_details->jetpack_secret,
2546            $jetpack_public
2547        );
2548
2549        if ( isset( $registration_details->token ) ) {
2550            /**
2551             * Fires when a user token is sent along with the registration data.
2552             *
2553             * @since 1.7.0
2554             * @since-jetpack 7.6.0
2555             *
2556             * @param object $token the administrator token for the newly registered site.
2557             */
2558            do_action( 'jetpack_site_registered_user_token', $registration_details->token );
2559        }
2560
2561        return true;
2562    }
2563
2564    /**
2565     * Attempts Jetpack registration.
2566     *
2567     * Automatic attempts are skipped in offline mode and held back while a previous
2568     * failure still stands. Pass `$force` for attempts a user or partner explicitly
2569     * asked for - those are never held back.
2570     *
2571     * @see Registration_Failure
2572     *
2573     * @since 9.11.0 Added the `$force` parameter, the offline mode check, and the retry backoff.
2574     *
2575     * @param bool $tos_agree Whether the user agreed to TOS.
2576     * @param bool $force     Whether this attempt was explicitly requested by a user.
2577     *
2578     * @return bool|WP_Error
2579     */
2580    public function try_registration( $tos_agree = true, $force = false ) {
2581        if ( ! $force ) {
2582            if ( ( new Status() )->is_offline_mode() ) {
2583                return new WP_Error(
2584                    'offline_mode',
2585                    __( 'Site is in offline mode; WordPress.com registration is not attempted.', 'jetpack-connection' ),
2586                    array( 'status' => 400 )
2587                );
2588            }
2589
2590            $suppressed_error = Registration_Failure::get_suppressed_error();
2591
2592            if ( $suppressed_error ) {
2593                Registration_Failure::record_suppressed_attempt(
2594                    $suppressed_error,
2595                    $this->get_plugin_slug()
2596                );
2597
2598                return $suppressed_error;
2599            }
2600        }
2601
2602        if ( $tos_agree ) {
2603            $terms_of_service = new Terms_Of_Service();
2604            $terms_of_service->agree();
2605        }
2606
2607        /**
2608         * Action fired when the user attempts the registration.
2609         *
2610         * @since 1.26.0
2611         */
2612        $pre_register = apply_filters( 'jetpack_pre_register', null );
2613
2614        if ( is_wp_error( $pre_register ) ) {
2615            return $pre_register;
2616        }
2617
2618        $tracking_data = array();
2619
2620        if ( null !== $this->get_plugin_slug() ) {
2621            $tracking_data['plugin_slug'] = $this->get_plugin_slug();
2622        }
2623
2624        $tracking = new Tracking();
2625        $tracking->record_user_event( 'jpc_register_begin', $tracking_data );
2626
2627        add_filter( 'jetpack_register_request_body', array( Utils::class, 'filter_register_request_body' ) );
2628
2629        $result = $this->register();
2630
2631        remove_filter( 'jetpack_register_request_body', array( Utils::class, 'filter_register_request_body' ) );
2632
2633        // If there was an error with registration and the site was not registered, record this so we can show a message.
2634        if ( ! $result || is_wp_error( $result ) ) {
2635            return $result;
2636        }
2637
2638        return true;
2639    }
2640
2641    /**
2642     * Get the reason the last registration attempt failed, if it still stands.
2643     *
2644     * Lets consuming plugins tell the user why the site is not connected - for
2645     * example that WordPress.com cannot reach a site on a private address - instead
2646     * of leaving the connection silently unavailable.
2647     *
2648     * @since 9.11.0
2649     *
2650     * @return array|null Failure details, or null if the last attempt did not fail or the site is connected.
2651     */
2652    public function get_registration_failure() {
2653        if ( $this->is_connected() ) {
2654            return null;
2655        }
2656
2657        return Registration_Failure::get();
2658    }
2659
2660    /**
2661     * Forget the last registration failure, so the next automatic attempt is not
2662     * held back by it.
2663     *
2664     * For consumers that trigger registration through code that cannot pass the
2665     * `$force` flag to `try_registration()`.
2666     *
2667     * @since 9.11.0
2668     *
2669     * @return void
2670     */
2671    public function clear_registration_failure() {
2672        Registration_Failure::clear();
2673    }
2674
2675    /**
2676     * Get the slug of the plugin this connection is for, if any.
2677     *
2678     * @return string|null
2679     */
2680    private function get_plugin_slug() {
2681        return $this->get_plugin() ? $this->get_plugin()->get_slug() : null;
2682    }
2683
2684    /**
2685     * Adds a parameter to the register request body
2686     *
2687     * @since 1.26.0
2688     *
2689     * @param string $name The name of the parameter to be added.
2690     * @param string $value The value of the parameter to be added.
2691     *
2692     * @throws \InvalidArgumentException If supplied arguments are not strings.
2693     * @return void
2694     */
2695    public function add_register_request_param( $name, $value ) {
2696        if ( ! is_string( $name ) || ! is_string( $value ) ) {
2697            throw new \InvalidArgumentException( 'name and value must be strings' );
2698        }
2699        self::$extra_register_params[ $name ] = $value;
2700    }
2701
2702    /**
2703     * Takes the response from the Jetpack register new site endpoint and
2704     * verifies it worked properly.
2705     *
2706     * @since 1.7.0
2707     * @since-jetpack 2.6.0
2708     *
2709     * @param mixed $response the response object, or the error object.
2710     * @return string|WP_Error A JSON object on success or WP_Error on failures
2711     **/
2712    protected function validate_remote_register_response( $response ) {
2713        if ( is_wp_error( $response ) ) {
2714            return new \WP_Error(
2715                'register_http_request_failed',
2716                $response->get_error_message()
2717            );
2718        }
2719
2720        $code   = wp_remote_retrieve_response_code( $response );
2721        $entity = wp_remote_retrieve_body( $response );
2722
2723        if ( $entity ) {
2724            $registration_response = json_decode( $entity );
2725        } else {
2726            $registration_response = false;
2727        }
2728
2729        $code_type = (int) ( $code / 100 );
2730        if ( 5 === $code_type ) {
2731            return new \WP_Error( 'wpcom_5??', $code );
2732        } elseif ( 408 === $code ) {
2733            return new \WP_Error( 'wpcom_408', $code );
2734        } elseif ( ! empty( $registration_response->error ) ) {
2735            if (
2736                'xml_rpc-32700' === $registration_response->error
2737                && ! function_exists( 'xml_parser_create' )
2738            ) {
2739                $error_description = __( "PHP's XML extension is not available. Jetpack requires the XML extension to communicate with WordPress.com. Please contact your hosting provider to enable PHP's XML extension.", 'jetpack-connection' );
2740            } else {
2741                $error_description = isset( $registration_response->error_description )
2742                    ? (string) $registration_response->error_description
2743                    : '';
2744            }
2745
2746            return new \WP_Error(
2747                (string) $registration_response->error,
2748                $error_description,
2749                $code
2750            );
2751        } elseif ( 200 !== $code ) {
2752            return new \WP_Error( 'wpcom_bad_response', $code );
2753        }
2754
2755        // Jetpack ID error block.
2756        if ( empty( $registration_response->jetpack_id ) ) {
2757            return new \WP_Error(
2758                'jetpack_id',
2759                /* translators: %s is an error message string */
2760                sprintf( __( 'Error Details: Jetpack ID is empty. Do not publicly post this error message! %s', 'jetpack-connection' ), $entity ),
2761                $entity
2762            );
2763        } elseif ( ! is_scalar( $registration_response->jetpack_id ) ) {
2764            return new \WP_Error(
2765                'jetpack_id',
2766                /* translators: %s is an error message string */
2767                sprintf( __( 'Error Details: Jetpack ID is not a scalar. Do not publicly post this error message! %s', 'jetpack-connection' ), $entity ),
2768                $entity
2769            );
2770        } elseif ( preg_match( '/[^0-9]/', $registration_response->jetpack_id ) ) {
2771            return new \WP_Error(
2772                'jetpack_id',
2773                /* translators: %s is an error message string */
2774                sprintf( __( 'Error Details: Jetpack ID begins with a numeral. Do not publicly post this error message! %s', 'jetpack-connection' ), $entity ),
2775                $entity
2776            );
2777        }
2778
2779        return $registration_response;
2780    }
2781
2782    /**
2783     * Adds a used nonce to a list of known nonces.
2784     *
2785     * @param int    $timestamp the current request timestamp.
2786     * @param string $nonce the nonce value.
2787     * @return bool whether the nonce is unique or not.
2788     *
2789     * @deprecated since 1.24.0
2790     * @see Nonce_Handler::add()
2791     */
2792    public function add_nonce( $timestamp, $nonce ) {
2793        _deprecated_function( __METHOD__, '1.24.0', 'Automattic\\Jetpack\\Connection\\Nonce_Handler::add' );
2794        return ( new Nonce_Handler() )->add( $timestamp, $nonce );
2795    }
2796
2797    /**
2798     * Cleans nonces that were saved when calling ::add_nonce.
2799     *
2800     * @todo Properly prepare the query before executing it.
2801     *
2802     * @param bool $all whether to clean even non-expired nonces.
2803     *
2804     * @deprecated since 1.24.0
2805     * @see Nonce_Handler::clean_all()
2806     */
2807    public function clean_nonces( $all = false ) {
2808        _deprecated_function( __METHOD__, '1.24.0', 'Automattic\\Jetpack\\Connection\\Nonce_Handler::clean_all' );
2809        ( new Nonce_Handler() )->clean_all( $all ? PHP_INT_MAX : ( time() - Nonce_Handler::LIFETIME ) );
2810    }
2811
2812    /**
2813     * Sets the Connection custom capabilities.
2814     *
2815     * @param string[] $caps    Array of the user's capabilities.
2816     * @param string   $cap     Capability name.
2817     * @param int      $user_id The user ID.
2818     * @param array    $args    Adds the context to the cap. Typically the object ID.
2819     */
2820    public function jetpack_connection_custom_caps( $caps, $cap, $user_id, $args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
2821        switch ( $cap ) {
2822            case 'jetpack_connect':
2823            case 'jetpack_reconnect':
2824                $is_offline_mode = ( new Status() )->is_offline_mode();
2825                if ( $is_offline_mode ) {
2826                    $caps = array( 'do_not_allow' );
2827                    break;
2828                }
2829                // Pass through. If it's not offline mode, these should match disconnect.
2830                // Let users disconnect if it's offline mode, just in case things glitch.
2831            case 'jetpack_disconnect':
2832                /**
2833                 * Filters the jetpack_disconnect capability.
2834                 *
2835                 * @since 1.14.2
2836                 *
2837                 * @param array An array containing the capability name.
2838                 */
2839                $caps = apply_filters( 'jetpack_disconnect_cap', array( 'manage_options' ) );
2840                break;
2841            case 'jetpack_connect_user':
2842                $is_offline_mode = ( new Status() )->is_offline_mode();
2843                if ( $is_offline_mode ) {
2844                    $caps = array( 'do_not_allow' );
2845                    break;
2846                }
2847                // With site connections in mind, non-admin users can connect their account only if a connection owner exists.
2848                $caps = $this->has_connected_owner() ? array( 'read' ) : array( 'manage_options' );
2849                break;
2850            case 'jetpack_unlink_user':
2851                $is_offline_mode = ( new Status() )->is_offline_mode();
2852                if ( $is_offline_mode ) {
2853                    $caps = array( 'do_not_allow' );
2854                    break;
2855                }
2856
2857                // Non-admins can always disconnect
2858                $caps = array( 'read' );
2859                break;
2860        }
2861        return $caps;
2862    }
2863
2864    /**
2865     * Builds the timeout limit for queries talking with the wpcom servers.
2866     *
2867     * Based on local php max_execution_time in php.ini
2868     *
2869     * @since 1.7.0
2870     * @since-jetpack 5.4.0
2871     * @return int
2872     **/
2873    public function get_max_execution_time() {
2874        $timeout = (int) ini_get( 'max_execution_time' );
2875
2876        // Ensure exec time set in php.ini.
2877        if ( ! $timeout ) {
2878            $timeout = 30;
2879        }
2880        return $timeout;
2881    }
2882
2883    /**
2884     * Sets a minimum request timeout, and returns the current timeout
2885     *
2886     * @since 1.7.0
2887     * @since-jetpack 5.4.0
2888     * @param int $min_timeout the minimum timeout value.
2889     **/
2890    public function set_min_time_limit( $min_timeout ) {
2891        $timeout = $this->get_max_execution_time();
2892        if ( $timeout < $min_timeout ) {
2893            $timeout = $min_timeout;
2894            set_time_limit( $timeout );
2895        }
2896        return $timeout;
2897    }
2898
2899    /**
2900     * Get our assumed site creation date.
2901     * Calculated based on the earlier date of either:
2902     * - Earliest admin user registration date.
2903     * - Earliest date of post of any post type.
2904     *
2905     * @since 1.7.0
2906     * @since-jetpack 7.2.0
2907     *
2908     * @return string Assumed site creation date and time.
2909     */
2910    public function get_assumed_site_creation_date() {
2911        $cached_date = get_transient( 'jetpack_assumed_site_creation_date' );
2912        if ( ! empty( $cached_date ) ) {
2913            return $cached_date;
2914        }
2915
2916        /**
2917         * We don't use the 'ID' field, but need it to overcome a WP caching bug: https://core.trac.wordpress.org/ticket/62003
2918         *
2919         * @todo Remote the 'ID' field from users fetching when the issue is fixed and Jetpack-supported WP versions move beyond it.
2920         */
2921        $earliest_registered_users  = get_users(
2922            array(
2923                'role'    => 'administrator',
2924                'orderby' => 'user_registered',
2925                'order'   => 'ASC',
2926                'fields'  => array( 'ID', 'user_registered' ),
2927                'number'  => 1,
2928            )
2929        );
2930        $earliest_registration_date = $earliest_registered_users[0]->user_registered;
2931
2932        $earliest_posts = get_posts(
2933            array(
2934                'posts_per_page' => 1,
2935                'post_type'      => 'any',
2936                'post_status'    => 'any',
2937                'orderby'        => 'date',
2938                'order'          => 'ASC',
2939            )
2940        );
2941
2942        // If there are no posts at all, we'll count only on user registration date.
2943        if ( $earliest_posts ) {
2944            $earliest_post_date = $earliest_posts[0]->post_date;
2945        } else {
2946            $earliest_post_date = PHP_INT_MAX;
2947        }
2948
2949        $assumed_date = min( $earliest_registration_date, $earliest_post_date );
2950        set_transient( 'jetpack_assumed_site_creation_date', $assumed_date );
2951
2952        return $assumed_date;
2953    }
2954
2955    /**
2956     * Adds the activation source string as a parameter to passed arguments.
2957     *
2958     * @todo Refactor to use rawurlencode() instead of urlencode().
2959     *
2960     * @param array $args arguments that need to have the source added.
2961     * @return array $amended arguments.
2962     */
2963    public static function apply_activation_source_to_args( $args ) {
2964        $activation_source = get_option( 'jetpack_activation_source' );
2965
2966        if ( ! empty( $activation_source[0] ) ) {
2967            // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.urlencode_urlencode
2968            $args['_as'] = urlencode( $activation_source[0] );
2969        }
2970
2971        if ( ! empty( $activation_source[1] ) ) {
2972            // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.urlencode_urlencode
2973            $args['_ak'] = urlencode( $activation_source[1] );
2974        }
2975
2976        return $args;
2977    }
2978
2979    /**
2980     * Generates two secret tokens and the end of life timestamp for them.
2981     *
2982     * @param string   $action  The action name.
2983     * @param int|bool $user_id The user identifier.
2984     * @param int      $exp     Expiration time in seconds.
2985     */
2986    public function generate_secrets( $action, $user_id = false, $exp = 600 ) {
2987        return ( new Secrets() )->generate( $action, $user_id, $exp );
2988    }
2989
2990    /**
2991     * Returns two secret tokens and the end of life timestamp for them.
2992     *
2993     * @deprecated 1.24.0 Use Automattic\Jetpack\Connection\Secrets->get() instead.
2994     *
2995     * @param string $action  The action name.
2996     * @param int    $user_id The user identifier.
2997     * @return string|array an array of secrets or an error string.
2998     */
2999    public function get_secrets( $action, $user_id ) {
3000        _deprecated_function( __METHOD__, '1.24.0', 'Automattic\\Jetpack\\Connection\\Secrets->get' );
3001        return ( new Secrets() )->get( $action, $user_id );
3002    }
3003
3004    /**
3005     * Deletes secret tokens in case they, for example, have expired.
3006     *
3007     * @deprecated 1.24.0 Use Automattic\Jetpack\Connection\Secrets->delete() instead.
3008     *
3009     * @param string $action  The action name.
3010     * @param int    $user_id The user identifier.
3011     */
3012    public function delete_secrets( $action, $user_id ) {
3013        _deprecated_function( __METHOD__, '1.24.0', 'Automattic\\Jetpack\\Connection\\Secrets->delete' );
3014        ( new Secrets() )->delete( $action, $user_id );
3015    }
3016
3017    /**
3018     * Deletes all connection tokens and transients from the local Jetpack site.
3019     * If the plugin object has been provided in the constructor, the function first checks
3020     * whether it's the only active connection.
3021     * If there are any other connections, the function will do nothing and return `false`
3022     * (unless `$ignore_connected_plugins` is set to `true`).
3023     *
3024     * @param bool $ignore_connected_plugins Delete the tokens even if there are other connected plugins.
3025     *
3026     * @return bool True if disconnected successfully, false otherwise.
3027     */
3028    public function delete_all_connection_tokens( $ignore_connected_plugins = false ) {
3029        // refuse to delete if we're not the last Jetpack plugin installed.
3030        if ( ! $ignore_connected_plugins && null !== $this->plugin && ! $this->plugin->is_only() ) {
3031            return false;
3032        }
3033
3034        /**
3035         * Fires upon the disconnect attempt.
3036         * Return `false` to prevent the disconnect.
3037         *
3038         * @since 1.14.2
3039         */
3040        if ( ! apply_filters( 'jetpack_connection_delete_all_tokens', true ) ) {
3041            return false;
3042        }
3043
3044        // The protected owner anchor is a local cache of a record WordPress.com owns. Dropping it
3045        // here keeps a disconnected site from carrying a lock that names a user who no longer holds
3046        // a token; the anchor is re-established from WordPress.com when the owner reconnects.
3047        \Jetpack_Options::delete_option(
3048            array(
3049                'master_user',
3050                'protected_owner',
3051                'time_diff',
3052                'fallback_no_verify_ssl_certs',
3053            )
3054        );
3055
3056        // Clear the memoized connection owner ID since it changed
3057        self::$connection_owner_id = null;
3058
3059        ( new Secrets() )->delete_all();
3060        $this->get_tokens()->delete_all();
3061
3062        // Delete cached connected user data.
3063        $transient_key = 'jetpack_connected_user_data_' . get_current_user_id();
3064        delete_transient( $transient_key );
3065
3066        // Delete the cached site record, which a later connection must not serve.
3067        self::delete_cached_site_data();
3068
3069        // Delete all XML-RPC errors.
3070        Error_Handler::get_instance()->delete_all_errors();
3071
3072        return true;
3073    }
3074
3075    /**
3076     * Tells WordPress.com to disconnect the site and clear all tokens from cached site.
3077     * If the plugin object has been provided in the constructor, the function first check
3078     * whether it's the only active connection.
3079     * If there are any other connections, the function will do nothing and return `false`
3080     * (unless `$ignore_connected_plugins` is set to `true`).
3081     *
3082     * @param bool $ignore_connected_plugins Delete the tokens even if there are other connected plugins.
3083     *
3084     * @return bool True if disconnected successfully, false otherwise.
3085     */
3086    public function disconnect_site_wpcom( $ignore_connected_plugins = false ) {
3087        if ( ! $ignore_connected_plugins && null !== $this->plugin && ! $this->plugin->is_only() ) {
3088            return false;
3089        }
3090
3091        if ( ( new Status() )->is_offline_mode() && ! apply_filters( 'jetpack_connection_disconnect_site_wpcom_offline_mode', false ) ) {
3092            // Prevent potential disconnect of the live site by removing WPCOM tokens.
3093            return false;
3094        }
3095
3096        /**
3097         * Fires upon the disconnect attempt.
3098         * Return `false` to prevent the disconnect.
3099         *
3100         * @since 1.14.2
3101         */
3102        if ( ! apply_filters( 'jetpack_connection_disconnect_site_wpcom', true, $this ) ) {
3103            return false;
3104        }
3105
3106        $xml = new Jetpack_IXR_Client();
3107        $xml->query( 'jetpack.deregister', get_current_user_id() );
3108
3109        return true;
3110    }
3111
3112    /**
3113     * Disconnect the plugin and remove the tokens.
3114     * This function will automatically perform "soft" or "hard" disconnect depending on whether other plugins are using the connection.
3115     * This is a proxy method to simplify the Connection package API.
3116     *
3117     * @see Manager::disconnect_site()
3118     *
3119     * @param boolean $disconnect_wpcom Should disconnect_site_wpcom be called.
3120     * @param bool    $ignore_connected_plugins Delete the tokens even if there are other connected plugins.
3121     * @return bool
3122     */
3123    public function remove_connection( $disconnect_wpcom = true, $ignore_connected_plugins = false ) {
3124
3125        $this->disconnect_site( $disconnect_wpcom, $ignore_connected_plugins );
3126
3127        return true;
3128    }
3129
3130    /**
3131     * Completely clearing up the connection, and initiating reconnect.
3132     *
3133     * @return true|WP_Error True if reconnected successfully, a `WP_Error` object otherwise.
3134     */
3135    public function reconnect() {
3136        ( new Tracking() )->record_user_event( 'restore_connection_reconnect' );
3137
3138        $this->disconnect_site_wpcom( true );
3139
3140        return $this->register();
3141    }
3142
3143    /**
3144     * Validate the tokens, and refresh the invalid ones.
3145     *
3146     * @since 9.8.1 When token validation is inconclusive, check the blog token on its own instead of assuming both are broken.
3147     *
3148     * @return string|bool|WP_Error True if connection restored or string indicating what's to be done next. A `WP_Error` object or false otherwise.
3149     */
3150    public function restore() {
3151        // If this is a site connection we need to trigger a full reconnection as our only secure means of
3152        // communication with WPCOM, aka the blog token, is compromised.
3153        if ( $this->is_site_connection() ) {
3154            return $this->reconnect();
3155        }
3156
3157        $validate_tokens_response = $this->get_tokens()->validate();
3158
3159        if ( is_array( $validate_tokens_response ) &&
3160            isset( $validate_tokens_response['blog_token']['is_healthy'] ) &&
3161            isset( $validate_tokens_response['user_token']['is_healthy'] ) ) {
3162            $blog_token_healthy = $validate_tokens_response['blog_token']['is_healthy'];
3163            $user_token_healthy = $validate_tokens_response['user_token']['is_healthy'];
3164        } else {
3165            // The paired health check could not run (a token is missing locally â€” e.g. a
3166            // deleted owner token â€” or the request failed): no evidence the blog token is
3167            // broken, and it's the one credential reconnect() would revoke for every user,
3168            // so check it on its own before that teardown.
3169            $blog_token_healthy = true === $this->get_tokens()->validate_blog_token();
3170            $user_token_healthy = false; // Unknown, treated as unhealthy.
3171        }
3172
3173        // Tokens are both valid, or both invalid. We can't fix the problem we don't see, so the full reconnection is needed.
3174        if ( $blog_token_healthy === $user_token_healthy ) {
3175            $result = $this->reconnect();
3176            return ( true === $result ) ? 'authorize' : $result;
3177        }
3178
3179        if ( ! $blog_token_healthy ) {
3180            return $this->refresh_blog_token();
3181        }
3182
3183        if ( ! $user_token_healthy ) {
3184            return ( true === $this->refresh_user_token() ) ? 'authorize' : false;
3185        }
3186
3187        return false;
3188    }
3189
3190    /**
3191     * Responds to a WordPress.com call to register the current site.
3192     * Should be changed to protected.
3193     *
3194     * @param array $registration_data Array of [ secret_1, user_id ].
3195     */
3196    public function handle_registration( array $registration_data ) {
3197        list( $registration_secret_1, $registration_user_id ) = $registration_data;
3198        if ( empty( $registration_user_id ) ) {
3199            return new \WP_Error( 'registration_state_invalid', __( 'Invalid Registration State', 'jetpack-connection' ), 400 );
3200        }
3201
3202        return ( new Secrets() )->verify( 'register', $registration_secret_1, (int) $registration_user_id );
3203    }
3204
3205    /**
3206     * Perform the API request to validate the blog and user tokens.
3207     *
3208     * @deprecated 1.24.0 Use Automattic\Jetpack\Connection\Tokens->validate_tokens() instead.
3209     *
3210     * @param int|null $user_id ID of the user we need to validate token for. Current user's ID by default.
3211     *
3212     * @return array|false|WP_Error The API response: `array( 'blog_token_is_healthy' => true|false, 'user_token_is_healthy' => true|false )`.
3213     */
3214    public function validate_tokens( $user_id = null ) {
3215        _deprecated_function( __METHOD__, '1.24.0', 'Automattic\\Jetpack\\Connection\\Tokens->validate' );
3216        return $this->get_tokens()->validate( $user_id );
3217    }
3218
3219    /**
3220     * Verify a Previously Generated Secret.
3221     *
3222     * @deprecated 1.24.0 Use Automattic\Jetpack\Connection\Secrets->verify() instead.
3223     *
3224     * @param string $action   The type of secret to verify.
3225     * @param string $secret_1 The secret string to compare to what is stored.
3226     * @param int    $user_id  The user ID of the owner of the secret.
3227     * @return \WP_Error|string WP_Error on failure, secret_2 on success.
3228     */
3229    public function verify_secrets( $action, $secret_1, $user_id ) {
3230        _deprecated_function( __METHOD__, '1.24.0', 'Automattic\\Jetpack\\Connection\\Secrets->verify' );
3231        return ( new Secrets() )->verify( $action, $secret_1, $user_id );
3232    }
3233
3234    /**
3235     * Responds to a WordPress.com call to authorize the current user.
3236     * Should be changed to protected.
3237     */
3238    public function handle_authorization() {
3239    }
3240
3241    /**
3242     * Obtains the auth token.
3243     *
3244     * @param array $data The request data.
3245     * @return object|\WP_Error Returns the auth token on success.
3246     *                          Returns a \WP_Error on failure.
3247     */
3248    public function get_token( $data ) {
3249        return $this->get_tokens()->get( $data, $this->api_url( 'token' ) );
3250    }
3251
3252    /**
3253     * Builds a URL to the Jetpack connection auth page.
3254     *
3255     * @since 2.7.6 Added optional $from and $raw parameters.
3256     *
3257     * @param WP_User|null $user     (optional) defaults to the current logged in user.
3258     * @param string|null  $redirect (optional) a redirect URL to use instead of the default.
3259     * @param bool|string  $from     If not false, adds 'from=$from' param to the connect URL.
3260     * @param bool         $raw If true, URL will not be escaped.
3261     *
3262     * @return string Connect URL.
3263     */
3264    public function get_authorization_url( $user = null, $redirect = null, $from = false, $raw = false ) {
3265        if ( empty( $user ) ) {
3266            $user = wp_get_current_user();
3267        }
3268
3269        $roles       = new Roles();
3270        $role        = $roles->translate_user_to_role( $user );
3271        $signed_role = $this->get_tokens()->sign_role( $role );
3272
3273        /**
3274         * Filter the URL of the first time the user gets redirected back to your site for connection
3275         * data processing.
3276         *
3277         * @since 1.7.0
3278         * @since-jetpack 8.0.0
3279         *
3280         * @param string $redirect_url Defaults to the site admin URL.
3281         */
3282        $processing_url = apply_filters( 'jetpack_connect_processing_url', admin_url( 'admin.php' ) );
3283
3284        /**
3285         * Filter the URL to redirect the user back to when the authorization process
3286         * is complete.
3287         *
3288         * @since 1.7.0
3289         * @since-jetpack 8.0.0
3290         *
3291         * @param string $redirect_url Defaults to the site URL.
3292         */
3293        $redirect = apply_filters( 'jetpack_connect_redirect_url', $redirect );
3294
3295        $secrets = ( new Secrets() )->generate( 'authorize', $user->ID, 2 * HOUR_IN_SECONDS );
3296
3297        /**
3298         * Filter the type of authorization.
3299         * 'calypso' completes authorization on wordpress.com/jetpack/connect
3300         * while 'jetpack' ( or any other value ) completes the authorization at jetpack.wordpress.com.
3301         *
3302         * @since 1.7.0
3303         * @since-jetpack 4.3.3
3304         *
3305         * @param string $auth_type Defaults to 'calypso', can also be 'jetpack'.
3306         */
3307        $auth_type = apply_filters( 'jetpack_auth_type', 'calypso' );
3308
3309        $body_args = array(
3310            'response_type'         => 'code',
3311            'client_id'             => \Jetpack_Options::get_option( 'id' ),
3312            'redirect_uri'          => add_query_arg(
3313                array(
3314                    'handler'  => 'jetpack-connection-webhooks',
3315                    'action'   => 'authorize',
3316                    '_wpnonce' => wp_create_nonce( "jetpack-authorize_{$role}_{$redirect}" ),
3317                    'redirect' => $redirect ? rawurlencode( $redirect ) : false,
3318                ),
3319                esc_url( $processing_url )
3320            ),
3321            'state'                 => $user->ID,
3322            'scope'                 => $signed_role,
3323            'user_email'            => $user->user_email,
3324            'user_login'            => $user->user_login,
3325            'is_active'             => $this->has_connected_owner(), // TODO Deprecate this.
3326            'jp_version'            => (string) Constants::get_constant( 'JETPACK__VERSION' ),
3327            'auth_type'             => $auth_type,
3328            'secret'                => $secrets['secret_1'],
3329            'blogname'              => get_option( 'blogname' ),
3330            'site_url'              => Urls::site_url(),
3331            'home_url'              => Urls::home_url(),
3332            'site_icon'             => get_site_icon_url(),
3333            'site_lang'             => get_locale(),
3334            'site_created'          => $this->get_assumed_site_creation_date(),
3335            'allow_site_connection' => ! $this->has_connected_owner(),
3336            'calypso_env'           => ( new Host() )->get_calypso_env(),
3337            'source'                => ( new Host() )->get_source_query(),
3338        );
3339
3340        // Include the slugs of every plugin currently using the Jetpack connection so wpcom
3341        // knows which integrations the site is authorizing on behalf of. `Plugin_Storage::get_all()`
3342        // returns a `WP_Error` when called before `plugins_loaded`; in that case we silently skip.
3343        $active_plugins = Plugin_Storage::get_all();
3344        if ( is_array( $active_plugins ) && ! empty( $active_plugins ) ) {
3345            $body_args['plugins'] = implode( ',', array_keys( $active_plugins ) );
3346        }
3347
3348        // Signal to Calypso that the site already has a connection owner so the
3349        // authorize page can show secondary-connection content where appropriate.
3350        if ( $this->has_connected_owner() ) {
3351            $body_args['has_connected_owner'] = true;
3352        }
3353
3354        /**
3355         * Filters the user connection request data for additional property addition.
3356         *
3357         * @since 1.7.0
3358         * @since-jetpack 8.0.0
3359         *
3360         * @param array $request_data request data.
3361         */
3362        $body = apply_filters( 'jetpack_connect_request_body', $body_args );
3363
3364        $body = static::apply_activation_source_to_args( urlencode_deep( $body ) );
3365
3366        $api_url = $this->api_url( 'authorize' );
3367
3368        $url = add_query_arg( $body, $api_url );
3369
3370        if ( is_network_admin() ) {
3371            $url = add_query_arg( 'is_multisite', network_admin_url( 'admin.php?page=jetpack-settings' ), $url );
3372        }
3373
3374        if ( $from ) {
3375            $url = add_query_arg( 'from', $from, $url );
3376        }
3377
3378        if ( $raw ) {
3379            $url = esc_url_raw( $url );
3380        }
3381
3382        /**
3383         * Filter the URL used when connecting a user to a WordPress.com account.
3384         *
3385         * @since 2.0.0
3386         * @since 2.7.6 Added $raw parameter.
3387         *
3388         * @param string $url Connection URL.
3389         * @param bool   $raw If true, URL will not be escaped.
3390         */
3391        return apply_filters( 'jetpack_build_authorize_url', $url, $raw );
3392    }
3393
3394    /**
3395     * Authorizes the user by obtaining and storing the user token.
3396     *
3397     * @since 9.8.1 Only a user with `jetpack_connect` can take a vacant connection owner slot.
3398     *
3399     * @param array $data The request data.
3400     * @return string|\WP_Error Returns a string on success.
3401     *                          Returns a \WP_Error on failure.
3402     */
3403    public function authorize( $data = array() ) {
3404        /**
3405         * Action fired when user authorization starts.
3406         *
3407         * @since 1.7.0
3408         * @since-jetpack 8.0.0
3409         */
3410        do_action( 'jetpack_authorize_starting' );
3411
3412        $roles = new Roles();
3413        $role  = $roles->translate_current_user_to_role();
3414
3415        if ( ! $role ) {
3416            return new \WP_Error( 'no_role', 'Invalid request.', 400 );
3417        }
3418
3419        $cap = $roles->translate_role_to_cap( $role );
3420        if ( ! $cap ) {
3421            return new \WP_Error( 'no_cap', 'Invalid request.', 400 );
3422        }
3423
3424        if ( ! empty( $data['error'] ) ) {
3425            return new \WP_Error( $data['error'], 'Error included in the request.', 400 );
3426        }
3427
3428        if ( ! isset( $data['state'] ) ) {
3429            return new \WP_Error( 'no_state', 'Request must include state.', 400 );
3430        }
3431
3432        if ( ! ctype_digit( $data['state'] ) ) {
3433            return new \WP_Error( $data['error'], 'State must be an integer.', 400 );
3434        }
3435
3436        $current_user_id = get_current_user_id();
3437        if ( $current_user_id !== (int) $data['state'] ) {
3438            return new \WP_Error( 'wrong_state', 'State does not match current user.', 400 );
3439        }
3440
3441        if ( empty( $data['code'] ) ) {
3442            return new \WP_Error( 'no_code', 'Request must include an authorization code.', 400 );
3443        }
3444
3445        $token = $this->get_tokens()->get( $data, $this->api_url( 'token' ) );
3446
3447        if ( is_wp_error( $token ) ) {
3448            $code = $token->get_error_code();
3449            if ( empty( $code ) ) {
3450                $code = 'invalid_token';
3451            }
3452            return new \WP_Error( $code, $token->get_error_message(), 400 );
3453        }
3454
3455        if ( ! $token ) {
3456            return new \WP_Error( 'no_token', 'Error generating token.', 400 );
3457        }
3458
3459        // Only a user who may manage the site connection takes a vacant owner slot; others link as secondary users.
3460        $is_connection_owner = ! $this->has_connected_owner() && current_user_can( 'jetpack_connect' );
3461
3462        $this->get_tokens()->update_user_token( $current_user_id, sprintf( '%s.%d', $token, $current_user_id ), $is_connection_owner );
3463
3464        // Delete cached connected user data, so a cached failure from the
3465        // previous (broken) token doesn't linger after reconnecting.
3466        delete_transient( "jetpack_connected_user_data_$current_user_id" );
3467
3468        /**
3469         * Fires after user has successfully received an auth token.
3470         *
3471         * @since 1.7.0
3472         * @since-jetpack 3.9.0
3473         */
3474        do_action( 'jetpack_user_authorized' );
3475
3476        if ( ! $is_connection_owner ) {
3477            /**
3478             * Action fired when a secondary user has been authorized.
3479             *
3480             * @since 1.7.0
3481             * @since-jetpack 8.0.0
3482             */
3483            do_action( 'jetpack_authorize_ending_linked' );
3484            return 'linked';
3485        }
3486
3487        /**
3488         * Action fired when the master user has been authorized.
3489         *
3490         * @since 1.7.0
3491         * @since-jetpack 8.0.0
3492         *
3493         * @param array $data The request data.
3494         */
3495        do_action( 'jetpack_authorize_ending_authorized', $data );
3496
3497        \Jetpack_Options::delete_raw_option( 'jetpack_last_connect_url_check' );
3498
3499        ( new Nonce_Handler() )->reschedule();
3500
3501        return 'authorized';
3502    }
3503
3504    /**
3505     * Disconnects from the Jetpack servers.
3506     * Forgets all connection details and tells the Jetpack servers to do the same.
3507     *
3508     * @param boolean $disconnect_wpcom Should disconnect_site_wpcom be called.
3509     * @param bool    $ignore_connected_plugins Delete the tokens even if there are other connected plugins.
3510     */
3511    public function disconnect_site( $disconnect_wpcom = true, $ignore_connected_plugins = true ) {
3512        if ( ! $ignore_connected_plugins && null !== $this->plugin && ! $this->plugin->is_only() ) {
3513            return false;
3514        }
3515
3516        wp_clear_scheduled_hook( 'jetpack_clean_nonces' );
3517
3518        ( new Nonce_Handler() )->clean_all();
3519
3520        Heartbeat::init()->deactivate();
3521
3522        /**
3523         * Fires before a site is disconnected.
3524         *
3525         * @since 1.36.3
3526         */
3527        do_action( 'jetpack_site_before_disconnected' );
3528
3529        // If the site is in an IDC because sync is not allowed,
3530        // let's make sure to not disconnect the production site.
3531        if ( $disconnect_wpcom ) {
3532            $tracking = new Tracking();
3533            $tracking->record_user_event( 'disconnect_site', array() );
3534
3535            $this->disconnect_site_wpcom( $ignore_connected_plugins );
3536        }
3537
3538        $this->delete_all_connection_tokens( $ignore_connected_plugins );
3539
3540        // Remove tracked package versions, since they depend on the Jetpack Connection.
3541        delete_option( Package_Version_Tracker::PACKAGE_VERSION_OPTION );
3542
3543        // A failure recorded before the site got connected some other way (a migration, a restore) no longer applies.
3544        Registration_Failure::clear();
3545
3546        $jetpack_unique_connection = \Jetpack_Options::get_option( 'unique_connection' );
3547        if ( $jetpack_unique_connection ) {
3548            // Check then record unique disconnection if site has never been disconnected previously.
3549            if ( - 1 === $jetpack_unique_connection['disconnected'] ) {
3550                $jetpack_unique_connection['disconnected'] = 1;
3551            } else {
3552                if ( 0 === $jetpack_unique_connection['disconnected'] ) {
3553                    $a8c_mc_stats_instance = new A8c_Mc_Stats();
3554                    $a8c_mc_stats_instance->add( 'connections', 'unique-disconnect' );
3555                    $a8c_mc_stats_instance->do_server_side_stats();
3556                }
3557                // increment number of times disconnected.
3558                $jetpack_unique_connection['disconnected'] += 1;
3559            }
3560
3561            \Jetpack_Options::update_option( 'unique_connection', $jetpack_unique_connection );
3562        }
3563
3564        /**
3565         * Fires when a site is disconnected.
3566         *
3567         * @since 1.30.1
3568         */
3569        do_action( 'jetpack_site_disconnected' );
3570    }
3571
3572    /**
3573     * The Base64 Encoding of the SHA1 Hash of the Input.
3574     *
3575     * @param string $text The string to hash.
3576     * @return string
3577     */
3578    public function sha1_base64( $text ) {
3579        return base64_encode( sha1( $text, true ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
3580    }
3581
3582    /**
3583     * This function mirrors Jetpack_Data::is_usable_domain() in the WPCOM codebase.
3584     *
3585     * @param string $domain The domain to check.
3586     *
3587     * @return bool|WP_Error
3588     */
3589    public function is_usable_domain( $domain ) {
3590
3591        // If it's empty, just fail out.
3592        if ( ! $domain ) {
3593            return new \WP_Error(
3594                'fail_domain_empty',
3595                /* translators: %1$s is a domain name. */
3596                sprintf( __( 'Domain `%1$s` just failed is_usable_domain check as it is empty.', 'jetpack-connection' ), $domain )
3597            );
3598        }
3599
3600        /**
3601         * Skips the usuable domain check when connecting a site.
3602         *
3603         * Allows site administrators with domains that fail gethostname-based checks to pass the request to WP.com
3604         *
3605         * @since 1.7.0
3606         * @since-jetpack 4.1.0
3607         *
3608         * @param bool If the check should be skipped. Default false.
3609         */
3610        if ( apply_filters( 'jetpack_skip_usuable_domain_check', false ) ) {
3611            return true;
3612        }
3613
3614        // None of the explicit localhosts.
3615        $forbidden_domains = array(
3616            'wordpress.com',
3617            'localhost',
3618            'localhost.localdomain',
3619            'local.wordpress.test',         // VVV pattern.
3620            'local.wordpress-trunk.test',   // VVV pattern.
3621            'src.wordpress-develop.test',   // VVV pattern.
3622            'build.wordpress-develop.test', // VVV pattern.
3623        );
3624        if ( in_array( $domain, $forbidden_domains, true ) ) {
3625            return new \WP_Error(
3626                'fail_domain_forbidden',
3627                sprintf(
3628                    /* translators: %1$s is a domain name. */
3629                    __(
3630                        'Domain `%1$s` just failed is_usable_domain check as it is in the forbidden array.',
3631                        'jetpack-connection'
3632                    ),
3633                    $domain
3634                )
3635            );
3636        }
3637
3638        // No .test or .local domains.
3639        if ( preg_match( '#\.(test|local)$#i', $domain ) ) {
3640            return new \WP_Error(
3641                'fail_domain_tld',
3642                sprintf(
3643                    /* translators: %1$s is a domain name. */
3644                    __(
3645                        'Domain `%1$s` just failed is_usable_domain check as it uses an invalid top level domain.',
3646                        'jetpack-connection'
3647                    ),
3648                    $domain
3649                )
3650            );
3651        }
3652
3653        // No WPCOM subdomains.
3654        if ( preg_match( '#\.WordPress\.com$#i', $domain ) ) {
3655            return new \WP_Error(
3656                'fail_subdomain_wpcom',
3657                sprintf(
3658                    /* translators: %1$s is a domain name. */
3659                    __(
3660                        'Domain `%1$s` just failed is_usable_domain check as it is a subdomain of WordPress.com.',
3661                        'jetpack-connection'
3662                    ),
3663                    $domain
3664                )
3665            );
3666        }
3667
3668        // If PHP was compiled without support for the Filter module (very edge case).
3669        if ( ! function_exists( 'filter_var' ) ) {
3670            // Just pass back true for now, and let wpcom sort it out.
3671            return true;
3672        }
3673
3674        $domain = preg_replace( '#^https?://#', '', untrailingslashit( $domain ) );
3675
3676        if ( filter_var( $domain, FILTER_VALIDATE_IP )
3677            && ! \Automattic\Jetpack\IP\Utils::ip_is_public( $domain )
3678        ) {
3679            return new \WP_Error(
3680                'fail_ip_forbidden',
3681                sprintf(
3682                    /* translators: %1$s is a domain name. */
3683                    __(
3684                        'IP address `%1$s` just failed is_usable_domain check as it is not a public IP address.',
3685                        'jetpack-connection'
3686                    ),
3687                    $domain
3688                )
3689            );
3690        }
3691
3692        return true;
3693    }
3694
3695    /**
3696     * Gets the requested token.
3697     *
3698     * @deprecated 1.24.0 Use Automattic\Jetpack\Connection\Tokens->get_access_token() instead.
3699     *
3700     * @param int|false    $user_id   false: Return the Blog Token. int: Return that user's User Token.
3701     * @param string|false $token_key If provided, check that the token matches the provided input.
3702     * @param bool|true    $suppress_errors If true, return a falsy value when the token isn't found; When false, return a descriptive WP_Error when the token isn't found.
3703     *
3704     * @return object|false
3705     *
3706     * @see $this->get_tokens()->get_access_token()
3707     */
3708    public function get_access_token( $user_id = false, $token_key = false, $suppress_errors = true ) {
3709        _deprecated_function( __METHOD__, '1.24.0', 'Automattic\\Jetpack\\Connection\\Tokens->get_access_token' );
3710        return $this->get_tokens()->get_access_token( $user_id, $token_key, $suppress_errors );
3711    }
3712
3713    /**
3714     * In some setups, $HTTP_RAW_POST_DATA can be emptied during some IXR_Server paths
3715     * since it is passed by reference to various methods.
3716     * Capture it here so we can verify the signature later.
3717     *
3718     * @param array $methods an array of available XMLRPC methods.
3719     * @return array the same array, since this method doesn't add or remove anything.
3720     */
3721    public function xmlrpc_methods( $methods ) {
3722        $this->raw_post_data = $GLOBALS['HTTP_RAW_POST_DATA'] ?? null;
3723        return $methods;
3724    }
3725
3726    /**
3727     * Resets the raw post data parameter for testing purposes.
3728     */
3729    public function reset_raw_post_data() {
3730        $this->raw_post_data = null;
3731    }
3732
3733    /**
3734     * Registering an additional method.
3735     *
3736     * @param array $methods an array of available XMLRPC methods.
3737     * @return array the amended array in case the method is added.
3738     */
3739    public function public_xmlrpc_methods( $methods ) {
3740        if ( array_key_exists( 'wp.getOptions', $methods ) ) {
3741            $methods['wp.getOptions'] = array( $this, 'jetpack_get_options' );
3742        }
3743        return $methods;
3744    }
3745
3746    /**
3747     * Handles a getOptions XMLRPC method call.
3748     *
3749     * @param array $args method call arguments.
3750     * @return array|IXR_Error An amended XMLRPC server options array.
3751     */
3752    public function jetpack_get_options( $args ) {
3753        global $wp_xmlrpc_server;
3754
3755        $wp_xmlrpc_server->escape( $args );
3756
3757        $username = $args[1];
3758        $password = $args[2];
3759
3760        $user = $wp_xmlrpc_server->login( $username, $password );
3761        if ( ! $user ) {
3762            return $wp_xmlrpc_server->error;
3763        }
3764
3765        $options   = array();
3766        $user_data = $this->get_connected_user_data();
3767        if ( is_array( $user_data ) ) {
3768            $options['jetpack_user_id']         = array(
3769                'desc'     => __( 'The WP.com user ID of the connected user', 'jetpack-connection' ),
3770                'readonly' => true,
3771                'value'    => $user_data['ID'],
3772            );
3773            $options['jetpack_user_login']      = array(
3774                'desc'     => __( 'The WP.com username of the connected user', 'jetpack-connection' ),
3775                'readonly' => true,
3776                'value'    => $user_data['login'],
3777            );
3778            $options['jetpack_user_email']      = array(
3779                'desc'     => __( 'The WP.com user email of the connected user', 'jetpack-connection' ),
3780                'readonly' => true,
3781                'value'    => $user_data['email'],
3782            );
3783            $options['jetpack_user_site_count'] = array(
3784                'desc'     => __( 'The number of sites of the connected WP.com user', 'jetpack-connection' ),
3785                'readonly' => true,
3786                'value'    => $user_data['site_count'],
3787            );
3788        }
3789        $wp_xmlrpc_server->blog_options = array_merge( $wp_xmlrpc_server->blog_options, $options );
3790        $args                           = stripslashes_deep( $args );
3791        return $wp_xmlrpc_server->wp_getOptions( $args );
3792    }
3793
3794    /**
3795     * Adds Jetpack-specific options to the output of the XMLRPC options method.
3796     *
3797     * @param array $options standard Core options.
3798     * @return array amended options.
3799     */
3800    public function xmlrpc_options( $options ) {
3801        $jetpack_client_id = false;
3802        if ( $this->is_connected() ) {
3803            $jetpack_client_id = \Jetpack_Options::get_option( 'id' );
3804        }
3805        $options['jetpack_version'] = array(
3806            'desc'     => __( 'Jetpack Plugin Version', 'jetpack-connection' ),
3807            'readonly' => true,
3808            'value'    => Constants::get_constant( 'JETPACK__VERSION' ),
3809        );
3810
3811        $options['jetpack_client_id'] = array(
3812            'desc'     => __( 'The Client ID/WP.com Blog ID of this site', 'jetpack-connection' ),
3813            'readonly' => true,
3814            'value'    => $jetpack_client_id,
3815        );
3816        return $options;
3817    }
3818
3819    /**
3820     * Resets the saved authentication state in between testing requests.
3821     */
3822    public function reset_saved_auth_state() {
3823        $this->xmlrpc_verification = null;
3824    }
3825
3826    /**
3827     * Sign a user role with the master access token.
3828     * If not specified, will default to the current user.
3829     *
3830     * @access public
3831     *
3832     * @param string $role    User role.
3833     * @param int    $user_id ID of the user.
3834     * @return string Signed user role.
3835     */
3836    public function sign_role( $role, $user_id = null ) {
3837        return $this->get_tokens()->sign_role( $role, $user_id );
3838    }
3839
3840    /**
3841     * Set the plugin instance.
3842     *
3843     * @param Plugin $plugin_instance The plugin instance.
3844     *
3845     * @return $this
3846     */
3847    public function set_plugin_instance( Plugin $plugin_instance ) {
3848        $this->plugin = $plugin_instance;
3849
3850        return $this;
3851    }
3852
3853    /**
3854     * Retrieve the plugin management object.
3855     *
3856     * @return Plugin|null
3857     */
3858    public function get_plugin() {
3859        return $this->plugin;
3860    }
3861
3862    /**
3863     * Get all connected plugins information, excluding those disconnected by user.
3864     * WARNING: the method cannot be called until Plugin_Storage::configure is called, which happens on plugins_loaded
3865     * Even if you don't use Jetpack Config, it may be introduced later by other plugins,
3866     * so please make sure not to run the method too early in the code.
3867     *
3868     * @return array|WP_Error
3869     */
3870    public function get_connected_plugins() {
3871        $maybe_plugins = Plugin_Storage::get_all();
3872
3873        if ( $maybe_plugins instanceof WP_Error ) {
3874            return $maybe_plugins;
3875        }
3876
3877        return $maybe_plugins;
3878    }
3879
3880    /**
3881     * Force plugin disconnect. After its called, the plugin will not be allowed to use the connection.
3882     * Note: this method does not remove any access tokens.
3883     *
3884     * @deprecated since 1.39.0
3885     * @return bool
3886     */
3887    public function disable_plugin() {
3888        return null;
3889    }
3890
3891    /**
3892     * Force plugin reconnect after user-initiated disconnect.
3893     * After its called, the plugin will be allowed to use the connection again.
3894     * Note: this method does not initialize access tokens.
3895     *
3896     * @deprecated since 1.39.0.
3897     * @return bool
3898     */
3899    public function enable_plugin() {
3900        return null;
3901    }
3902
3903    /**
3904     * Whether the plugin is allowed to use the connection, or it's been disconnected by user.
3905     * If no plugin slug was passed into the constructor, always returns true.
3906     *
3907     * @deprecated 1.42.0 This method no longer has a purpose after the removal of the soft disconnect feature.
3908     *
3909     * @return bool
3910     */
3911    public function is_plugin_enabled() {
3912        return true;
3913    }
3914
3915    /**
3916     * Perform the API request to refresh the blog token.
3917     * Note that we are making this request on behalf of the Jetpack master user,
3918     * given they were (most probably) the ones that registered the site at the first place.
3919     *
3920     * @return WP_Error|bool The result of updating the blog_token option.
3921     */
3922    public function refresh_blog_token() {
3923        ( new Tracking() )->record_user_event( 'restore_connection_refresh_blog_token' );
3924
3925        $blog_id = \Jetpack_Options::get_option( 'id' );
3926        if ( ! $blog_id ) {
3927            return new WP_Error( 'site_not_registered', 'Site not registered.' );
3928        }
3929
3930        $url     = sprintf(
3931            '%s/%s/v%s/%s',
3932            Constants::get_constant( 'JETPACK__WPCOM_JSON_API_BASE' ),
3933            'wpcom',
3934            '2',
3935            'sites/' . $blog_id . '/jetpack-refresh-blog-token'
3936        );
3937        $method  = 'POST';
3938        $user_id = get_current_user_id();
3939
3940        $response = Client::remote_request( compact( 'url', 'method', 'user_id' ) );
3941
3942        if ( is_wp_error( $response ) ) {
3943            return new WP_Error( 'refresh_blog_token_http_request_failed', $response->get_error_message() );
3944        }
3945
3946        $code   = wp_remote_retrieve_response_code( $response );
3947        $entity = wp_remote_retrieve_body( $response );
3948
3949        if ( $entity ) {
3950            $json = json_decode( $entity );
3951        } else {
3952            $json = false;
3953        }
3954
3955        if ( 200 !== $code ) {
3956            if ( empty( $json->code ) ) {
3957                return new WP_Error( 'unknown', '', $code );
3958            }
3959
3960            /* translators: Error description string. */
3961            $error_description = isset( $json->message ) ? sprintf( __( 'Error Details: %s', 'jetpack-connection' ), (string) $json->message ) : '';
3962
3963            return new WP_Error( (string) $json->code, $error_description, $code );
3964        }
3965
3966        if ( empty( $json->jetpack_secret ) || ! is_scalar( $json->jetpack_secret ) ) {
3967            return new WP_Error( 'jetpack_secret', '', $code );
3968        }
3969
3970        Error_Handler::get_instance()->delete_all_errors();
3971
3972        return $this->get_tokens()->update_blog_token( (string) $json->jetpack_secret );
3973    }
3974
3975    /**
3976     * Disconnect the user from WP.com, and initiate the reconnect process.
3977     *
3978     * @since 9.8.0 Added the `$force` parameter.
3979     *
3980     * @param bool $force Whether to remove the local token even if WordPress.com does not confirm the unlink.
3981     *                    When false, only the current user's own token is refreshed, never the owner's,
3982     *                    and only over a healthy blog token.
3983     * @return true|string|WP_Error True when forced. Otherwise 'authorize' when the user should authorize again, a `WP_Error` object on failure.
3984     */
3985    public function refresh_user_token( $force = true ) {
3986        $user_id = get_current_user_id();
3987
3988        if ( ! $force ) {
3989            // Unlinking the owner would leave the site without one.
3990            if ( ! $user_id || $this->is_site_connection() || $this->get_connection_owner_id() === $user_id ) {
3991                return new WP_Error(
3992                    'restore_requires_administrator',
3993                    __( 'An administrator needs to restore the Jetpack connection.', 'jetpack-connection' ),
3994                    array( 'status' => 403 )
3995                );
3996            }
3997
3998            // Relinking goes over the blog token, so it must work before anything is unlinked.
3999            $blog_token_health = $this->get_tokens()->validate_blog_token();
4000
4001            if ( is_wp_error( $blog_token_health ) ) {
4002                return new WP_Error(
4003                    'restore_check_failed',
4004                    __( 'The site connection could not be checked. Please try again shortly.', 'jetpack-connection' ),
4005                    array( 'status' => 503 )
4006                );
4007            }
4008
4009            if ( true !== $blog_token_health ) {
4010                return new WP_Error(
4011                    'restore_requires_administrator',
4012                    __( 'The site connection is broken. An administrator needs to restore it before you can reconnect your account.', 'jetpack-connection' ),
4013                    array( 'status' => 409 )
4014                );
4015            }
4016        }
4017
4018        // A forced refresh unlinks even without a stored token, as it always has.
4019        if ( $force || $this->is_user_connected( $user_id ) ) {
4020            ( new Tracking() )->record_user_event( 'restore_connection_refresh_user_token' );
4021
4022            // Unforced, the local token only goes once WordPress.com has unlinked it.
4023            $unlinked = $this->disconnect_user( $force ? null : $user_id, $force, $force );
4024
4025            if ( ! $force && ! $unlinked ) {
4026                return new WP_Error(
4027                    'restore_unlink_failed',
4028                    __( 'Your account could not be disconnected from WordPress.com. Please try again.', 'jetpack-connection' ),
4029                    array( 'status' => 502 )
4030                );
4031            }
4032        }
4033
4034        return $force ? true : 'authorize';
4035    }
4036
4037    /**
4038     * Fetches a signed token.
4039     *
4040     * @deprecated 1.24.0 Use Automattic\Jetpack\Connection\Tokens->get_signed_token() instead.
4041     *
4042     * @param object $token the token.
4043     * @return WP_Error|string a signed token
4044     */
4045    public function get_signed_token( $token ) {
4046        _deprecated_function( __METHOD__, '1.24.0', 'Automattic\\Jetpack\\Connection\\Tokens->get_signed_token' );
4047        return $this->get_tokens()->get_signed_token( $token );
4048    }
4049
4050    /**
4051     * If the site-level connection is active, add the list of plugins using connection to the heartbeat (except Jetpack itself)
4052     *
4053     * @since 6.11.0 Add the list of Jetpack package versions to the heartbeat.
4054     * @since 8.7.4 Add the missing connection owner and XML-RPC error stats to the heartbeat.
4055     * @since 8.7.9 Add the site environment stats (WordPress/PHP versions, etc.) to the heartbeat.
4056     *
4057     * @param array $stats The Heartbeat stats array.
4058     * @return array $stats
4059     */
4060    public function add_stats_to_heartbeat( $stats ) {
4061
4062        if ( ! $this->is_connected() ) {
4063            return $stats;
4064        }
4065
4066        $active_plugins_using_connection = Plugin_Storage::get_all();
4067        foreach ( array_keys( $active_plugins_using_connection ) as $plugin_slug ) {
4068            if ( 'jetpack' !== $plugin_slug ) {
4069                $stats_group             = isset( $active_plugins_using_connection['jetpack'] ) ? 'combined-connection' : 'standalone-connection';
4070                $stats[ $stats_group ][] = $plugin_slug;
4071            }
4072        }
4073
4074        $stats['jetpack_package_versions'] = apply_filters( 'jetpack_package_versions', array() );
4075
4076        $stats['identitycrisis'] = Identity_Crisis::check_identity_crisis() ? 'yes' : 'no';
4077
4078        // Missing the connection owner?
4079        $stats['missing-owner'] = $this->is_missing_connection_owner();
4080
4081        $xmlrpc_errors = \Jetpack_Options::get_option( 'xmlrpc_errors', array() );
4082        if ( $xmlrpc_errors ) {
4083            $stats['xmlrpc-errors'] = implode( ',', array_keys( $xmlrpc_errors ) );
4084            \Jetpack_Options::delete_option( 'xmlrpc_errors' );
4085        }
4086
4087        // Site environment stats (WordPress/PHP versions, site configuration, etc.).
4088        $stats = array_merge( $stats, Heartbeat::get_environment_stats() );
4089
4090        return $stats;
4091    }
4092
4093    /**
4094     * Records a failed XML-RPC signature verification so it can be reported in the heartbeat.
4095     *
4096     * We don't want to expose a detailed error message about why a request failed
4097     * signature verification, as doing so could leak information. Instead, we track
4098     * that the error occurred via a Jetpack option and send that data back in the
4099     * heartbeat. All this does is record the error code, but it's enough to find trends.
4100     *
4101     * @since 8.7.4
4102     *
4103     * @param \WP_Error $xmlrpc_error The error produced during signature validation.
4104     * @return void
4105     */
4106    public function track_xmlrpc_error( $xmlrpc_error ) {
4107        $code = is_wp_error( $xmlrpc_error )
4108            ? $xmlrpc_error->get_error_code()
4109            : 'should-not-happen';
4110
4111        $xmlrpc_errors = \Jetpack_Options::get_option( 'xmlrpc_errors', array() );
4112        if ( isset( $xmlrpc_errors[ $code ] ) && $xmlrpc_errors[ $code ] ) {
4113            // No need to update the option if we already have this code stored.
4114            return;
4115        }
4116        $xmlrpc_errors[ $code ] = true;
4117
4118        \Jetpack_Options::update_option( 'xmlrpc_errors', $xmlrpc_errors, false );
4119    }
4120
4121    /**
4122     * Get the WPCOM or self-hosted site ID.
4123     *
4124     * @param bool $quiet Return null instead of an error.
4125     *
4126     * @return int|WP_Error|null
4127     */
4128    public static function get_site_id( $quiet = false ) {
4129        $is_wpcom = ( defined( 'IS_WPCOM' ) && IS_WPCOM );
4130        $site_id  = $is_wpcom ? get_current_blog_id() : \Jetpack_Options::get_option( 'id' );
4131        if ( ! $site_id ) {
4132            return $quiet
4133                ? null
4134                : new \WP_Error(
4135                    'unavailable_site_id',
4136                    __( 'Sorry, something is wrong with your Jetpack connection.', 'jetpack-connection' ),
4137                    403
4138                );
4139        }
4140        return (int) $site_id;
4141    }
4142
4143    /**
4144     * Check if Jetpack is ready for uninstall cleanup.
4145     *
4146     * @param string $current_plugin_slug The current plugin's slug.
4147     *
4148     * @return bool
4149     */
4150    public static function is_ready_for_cleanup( $current_plugin_slug ) {
4151        $active_plugins = get_option( Plugin_Storage::ACTIVE_PLUGINS_OPTION_NAME );
4152
4153        return empty( $active_plugins ) || ! is_array( $active_plugins )
4154            || ( count( $active_plugins ) === 1 && array_key_exists( $current_plugin_slug, $active_plugins ) );
4155    }
4156}