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