PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.7
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.7
16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 All 506 releases
jetpack / jetpack_vendor / automattic / jetpack-connection / src / class-manager.php

class-manager.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.7, at jetpack_vendor/automattic/jetpack-connection/src/class-manager.php

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