PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.2
Jetpack – WP Security, Backup, Speed, & Growth v16.2
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.2, at jetpack_vendor/automattic/jetpack-connection/src/class-manager.php

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