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