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