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
jetpack / jetpack_vendor / automattic / jetpack-my-jetpack / src / class-jetpack-manage.php

class-jetpack-manage.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3, at jetpack_vendor/automattic/jetpack-my-jetpack/src/class-jetpack-manage.php

554 lines 17.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Tools to manage things related to "Jetpack Manage"
4 * - Add Jetpack Manage menu item.
5 * - Keep track of whether a user is an agency (used by the menu item and the banner)
6 *
7 * @package automattic/my-jetpack
8 */
9
10 namespace Automattic\Jetpack\My_Jetpack;
11
12 use Automattic\Jetpack\Admin_UI\Admin_Menu;
13 use Automattic\Jetpack\Connection\Client;
14 use Automattic\Jetpack\Connection\Manager as Connection_Manager;
15 use Automattic\Jetpack\Connection\Utils;
16 use Automattic\Jetpack\Redirect;
17 use WP_Error;
18 use WP_Rest_Response;
19
20 /**
21 * Jetpack Manage features in My Jetpack.
22 */
23 class Jetpack_Manage {
24 /**
25 * User meta holding the partner type WordPress.com last reported for that user.
26 *
27 * Keyed per user because the lookup is signed as one, and stored rather than cached because
28 * the sidebar needs an answer on every admin page load without waiting for a request.
29 *
30 * @var string
31 */
32 const PARTNER_TYPE_USER_META_KEY = 'jetpack_partner_type';
33
34 /**
35 * Cron hook that looks a user's partner type up and stores it.
36 *
37 * @var string
38 */
39 const PARTNER_TYPE_REFRESH_HOOK = 'jetpack_manage_refresh_partner_type';
40
41 /**
42 * Prefix of the transient that backs off after a lookup failed to produce an answer.
43 *
44 * The ID of the user being looked up completes the key.
45 *
46 * @var string
47 */
48 const PARTNER_TYPE_RETRY_TRANSIENT_PREFIX = 'jetpack_partner_type_retry_';
49
50 /**
51 * Key of the transient this class used before the answer moved to per-user meta.
52 *
53 * @deprecated 6.4.1 Nothing reads it; the answer now lives in PARTNER_TYPE_USER_META_KEY.
54 *
55 * @var string
56 */
57 const PARTNER_TYPE_TRANSIENT_KEY = 'jetpack_partner_type';
58
59 /**
60 * Stored partner type when the lookup found that this user has no partner account.
61 *
62 * "No partner" is a real answer and needs a value of its own to be distinguishable from
63 * "never looked up", which is what an absent meta value means.
64 *
65 * @var string
66 */
67 private const NO_PARTNER = 'none';
68
69 /**
70 * How long a stored partner type is trusted before a refresh is scheduled.
71 *
72 * @var int
73 */
74 private const PARTNER_TYPE_MAX_AGE = DAY_IN_SECONDS;
75
76 /**
77 * How long after a session starts the refresh runs.
78 *
79 * Far enough out to stay clear of the login and the first page loads after it; the stored
80 * answer is what the sidebar reads in the meantime.
81 *
82 * @var int
83 */
84 private const PARTNER_TYPE_REFRESH_DELAY = 5 * MINUTE_IN_SECONDS;
85
86 /**
87 * How much random delay is added on top, so simultaneous logins do not all fire at once.
88 *
89 * WP-Cron runs every due event in one pass, and each of these can wait on WordPress.com for
90 * the Client's default timeout, so an unspread burst lands as one long serial run.
91 *
92 * @var int
93 */
94 private const PARTNER_TYPE_REFRESH_JITTER = 5 * MINUTE_IN_SECONDS;
95
96 /**
97 * How long to wait before asking again after a lookup that produced no answer.
98 *
99 * @var int
100 */
101 private const PARTNER_TYPE_RETRY_DELAY = 15 * MINUTE_IN_SECONDS;
102
103 /**
104 * How long past its time a queued refresh is left alone before being treated as abandoned.
105 *
106 * WP-Cron normally runs at `shutdown`, after `admin_init`, so a refresh that has only just come
107 * due is about to run and must not be rescheduled out from under it.
108 *
109 * @var int
110 */
111 private const PARTNER_TYPE_OVERDUE_GRACE = HOUR_IN_SECONDS;
112
113 /**
114 * Initialize the class and hooks needed.
115 */
116 public static function init() {
117 add_action( 'admin_menu', array( self::class, 'add_submenu_jetpack' ) );
118
119 // Both only schedule. `admin_init` also covers older sessions, and SSO, whose `wp_login`
120 // fires on a GET that the Jetpack plugin does not load this package for.
121 add_action( 'wp_login', array( self::class, 'schedule_partner_type_refresh_on_login' ), 10, 2 );
122 add_action( 'admin_init', array( self::class, 'maybe_schedule_partner_type_refresh' ) );
123 add_action( self::PARTNER_TYPE_REFRESH_HOOK, array( self::class, 'refresh_partner_type_if_stale' ) );
124
125 add_action( 'jetpack_unlinked_user', array( self::class, 'forget_partner_type' ) );
126 }
127
128 /**
129 * Register the REST API routes.
130 *
131 * @return void
132 */
133 public static function register_rest_endpoints() {
134 register_rest_route(
135 'my-jetpack/v1',
136 'jetpack-manage/data',
137 array(
138 'methods' => \WP_REST_Server::READABLE,
139 'callback' => __CLASS__ . '::get_jetpack_manage_data',
140 'permission_callback' => __CLASS__ . '::permissions_callback',
141 )
142 );
143
144 register_rest_route(
145 'my-jetpack/v1',
146 'jetpack-manage/dismiss-banner',
147 array(
148 'methods' => \WP_REST_Server::EDITABLE,
149 'callback' => __CLASS__ . '::dismiss_banner',
150 'permission_callback' => __CLASS__ . '::permissions_callback',
151 )
152 );
153 }
154
155 /**
156 * Check user capabilities to access historically active modules.
157 *
158 * @access public
159 * @static
160 *
161 * @return true|WP_Error
162 */
163 public static function permissions_callback() {
164 return current_user_can( 'manage_options' );
165 }
166
167 /**
168 * The page to be added to submenu
169 *
170 * @return void|null|string The resulting page's hook_suffix
171 */
172 public static function add_submenu_jetpack() {
173 // Before could_use_jp_manage(): this reads meta, that can call WordPress.com.
174 if ( ! self::is_agency_account() ) {
175 return;
176 }
177
178 // Do not display the menu if the user has < 2 sites.
179 if ( ! self::could_use_jp_manage( 2 ) ) {
180 return;
181 }
182
183 $args = array();
184
185 $blog_id = Connection_Manager::get_site_id( true );
186 if ( $blog_id ) {
187 $args = array( 'site' => $blog_id );
188 }
189
190 $position = defined( Admin_Menu::class . '::POSITION_EXTERNAL' ) ? Admin_Menu::POSITION_EXTERNAL : 100;
191
192 return Admin_Menu::add_menu(
193 __( 'Jetpack Manage', 'jetpack-my-jetpack' ),
194 _x( 'Jetpack Manage', 'product name shown in menu', 'jetpack-my-jetpack' ) . ' <span aria-hidden="true">↗</span>',
195 'manage_options',
196 esc_url( Redirect::get_url( 'cloud-manage-dashboard-wp-menu', $args ) ),
197 null,
198 $position,
199 array( 'key' => 'jetpack-manage' )
200 );
201 }
202
203 /**
204 * Check if the user has enough sites to be able to use Jetpack Manage.
205 *
206 * @param int $min_sites Minimum number of sites to be able to use Jetpack Manage.
207 *
208 * @return bool Return true if the user has enough sites to be able to use Jetpack Manage.
209 */
210 public static function could_use_jp_manage( $min_sites = 2 ) {
211 // Only proceed if the user is connected to WordPress.com.
212 if ( ! ( new Connection_Manager() )->is_user_connected() ) {
213 return false;
214 }
215
216 // Do not display the menu if Jetpack plugin is not installed.
217 if ( ! class_exists( 'Jetpack' ) ) {
218 return false;
219 }
220
221 // Do not display the menu on Multisite.
222 if ( is_multisite() ) {
223 return false;
224 }
225
226 // Check if the user has the minimum number of sites.
227 $user_data = ( new Connection_Manager() )->get_connected_user_data( get_current_user_id() );
228 if ( ! isset( $user_data['site_count'] ) || $user_data['site_count'] < $min_sites ) {
229 return false;
230 }
231
232 return true;
233 }
234
235 /**
236 * Check if the user is a partner/agency.
237 *
238 * Answers from what the last lookup stored and never makes a request, because the sidebar
239 * asks on every admin page load. A user nobody has looked up yet reads as not an agency.
240 *
241 * @return bool Return true if the user is a partner/agency, otherwise false.
242 */
243 public static function is_agency_account() {
244 // Only proceed if the user is connected to WordPress.com.
245 if ( ! ( new Connection_Manager() )->is_user_connected() ) {
246 return false;
247 }
248
249 $stored = self::get_stored_partner_type( get_current_user_id() );
250
251 return null !== $stored && 'agency' === $stored['type'];
252 }
253
254 /**
255 * Check if the user is a partner/agency, looking them up first if the answer is stale.
256 *
257 * Only for a caller that can wait on WordPress.com, which rules out any page render.
258 *
259 * @return bool Return true if the user is a partner/agency, otherwise false.
260 */
261 private static function is_agency_account_now() {
262 self::refresh_partner_type_if_stale( get_current_user_id() );
263
264 return self::is_agency_account();
265 }
266
267 /**
268 * Schedule a partner type refresh for the user who just logged in.
269 *
270 * @param string $user_login Username, unused.
271 * @param \WP_User|null $user The user who logged in.
272 * @return void
273 */
274 public static function schedule_partner_type_refresh_on_login( $user_login, $user = null ) {
275 if ( $user instanceof \WP_User ) {
276 self::maybe_schedule_partner_type_refresh( $user->ID );
277 }
278 }
279
280 /**
281 * Queue a partner type lookup, unless a fresh answer or a pending job makes it pointless.
282 *
283 * Every check here reads options or user meta, so this stays free to call on `admin_init`.
284 *
285 * @param int|string|null $user_id User to look up. Anything falsy means the current user,
286 * which is what `admin_init` passes: `''`, not nothing.
287 * @return void
288 */
289 public static function maybe_schedule_partner_type_refresh( $user_id = null ) {
290 $user_id = $user_id ? (int) $user_id : get_current_user_id();
291
292 if ( ! self::could_ever_show_manage( $user_id ) ) {
293 return;
294 }
295
296 // Nothing to ask WordPress.com about a user it does not know.
297 if ( ! ( new Connection_Manager() )->is_user_connected( $user_id ) ) {
298 return;
299 }
300
301 if ( ! self::is_partner_type_stale( $user_id ) || self::is_backing_off( $user_id ) ) {
302 return;
303 }
304
305 $args = array( $user_id );
306 $next = wp_next_scheduled( self::PARTNER_TYPE_REFRESH_HOOK, $args );
307
308 if ( $next > time() - self::PARTNER_TYPE_OVERDUE_GRACE ) {
309 return;
310 }
311
312 // Long overdue means cron is not running it, and wp_next_scheduled() would keep reporting
313 // it forever, suppressing every later attempt.
314 if ( $next ) {
315 wp_unschedule_event( $next, self::PARTNER_TYPE_REFRESH_HOOK, $args );
316 }
317
318 wp_schedule_single_event(
319 time() + self::PARTNER_TYPE_REFRESH_DELAY + wp_rand( 0, self::PARTNER_TYPE_REFRESH_JITTER ),
320 self::PARTNER_TYPE_REFRESH_HOOK,
321 $args
322 );
323 }
324
325 /**
326 * Look a user's partner type up at WordPress.com and store it.
327 *
328 * Signs as `$user_id` explicitly rather than through `wpcom_json_api_request_as_user()`,
329 * which signs as the current user — and a cron request has none.
330 *
331 * @param int $user_id User to look up.
332 * @return void
333 */
334 public static function refresh_partner_type( $user_id ) {
335 $user_id = (int) $user_id;
336
337 $connection = new Connection_Manager();
338
339 if ( ! $user_id || ! $connection->is_user_connected( $user_id ) ) {
340 return;
341 }
342
343 // An answer that cannot be tied to an account could never be checked for a mismatch.
344 $wpcom_user_id = $connection->resolve_wpcom_user_id( $user_id );
345 if ( ! $wpcom_user_id ) {
346 self::back_off( $user_id );
347 return;
348 }
349
350 $request_args = Client::validate_args_for_wpcom_json_api_request( '/jetpack-partners', '2', array( 'method' => 'GET' ) );
351 $request_args['user_id'] = $user_id;
352
353 $wpcom_response = Client::remote_request( $request_args );
354 $response_code = (int) wp_remote_retrieve_response_code( $wpcom_response );
355
356 // Only 200 (the record) and 403 ("no partner account") settle it; storing anything else
357 // would read as "not an agency" for a day.
358 if ( is_wp_error( $wpcom_response ) || ! in_array( $response_code, array( 200, 403 ), true ) ) {
359 self::back_off( $user_id );
360 return;
361 }
362
363 $partner_data = 200 === $response_code
364 ? json_decode( wp_remote_retrieve_body( $wpcom_response ) )
365 : array();
366
367 // A 200 that did not parse is a truncated body or an error page, not an empty answer.
368 if ( ! is_array( $partner_data ) ) {
369 self::back_off( $user_id );
370 return;
371 }
372
373 // The endpoint returns a single-element array; it uses Jetpack_Partner::find_by_owner.
374 $partner_type = count( $partner_data ) === 1 && isset( $partner_data[0]->partner_type )
375 ? $partner_data[0]->partner_type
376 : self::NO_PARTNER;
377
378 delete_transient( self::retry_transient_key( $user_id ) );
379
380 update_user_meta(
381 $user_id,
382 self::PARTNER_TYPE_USER_META_KEY,
383 array(
384 'type' => $partner_type,
385 'time' => time(),
386 'wpcom_user_id' => $wpcom_user_id,
387 )
388 );
389 }
390
391 /**
392 * Drop a user's stored partner type when they disconnect from WordPress.com.
393 *
394 * @param int $user_id Disconnected user.
395 * @return void
396 */
397 public static function forget_partner_type( $user_id ) {
398 $user_id = (int) $user_id;
399
400 delete_user_meta( $user_id, self::PARTNER_TYPE_USER_META_KEY );
401 delete_transient( self::retry_transient_key( $user_id ) );
402
403 // A queued refresh would outlive the reason it was queued for.
404 wp_clear_scheduled_hook( self::PARTNER_TYPE_REFRESH_HOOK, array( $user_id ) );
405 }
406
407 /**
408 * The partner type stored for a user, unless it describes a different WordPress.com account.
409 *
410 * A binding that has gone to 0 counts as different: a token rewrite is what clears it.
411 *
412 * @param int $user_id User to read.
413 * @return array{type: string, time: int, wpcom_user_id: int}|null Null when nothing usable is stored.
414 */
415 private static function get_stored_partner_type( $user_id ) {
416 $user_id = (int) $user_id;
417 $stored = $user_id ? get_user_meta( $user_id, self::PARTNER_TYPE_USER_META_KEY, true ) : '';
418
419 if ( ! is_array( $stored ) || ! isset( $stored['type'] ) || ! isset( $stored['time'] ) ) {
420 return null;
421 }
422
423 if ( ! empty( $stored['wpcom_user_id'] ) && (int) $stored['wpcom_user_id'] !== Utils::get_wpcom_user_id( $user_id ) ) {
424 return null;
425 }
426
427 return $stored;
428 }
429
430 /**
431 * Whether a user's stored partner type is missing or old enough to ask again.
432 *
433 * @param int $user_id User to check.
434 * @return bool
435 */
436 private static function is_partner_type_stale( $user_id ) {
437 $stored = self::get_stored_partner_type( $user_id );
438
439 return null === $stored || $stored['time'] <= time() - self::PARTNER_TYPE_MAX_AGE;
440 }
441
442 /**
443 * Whether anything on this site could ever show this user the answer.
444 *
445 * The same cheap conditions the menu item and the REST payload require, minus the site count,
446 * which can itself call WordPress.com.
447 *
448 * @param int $user_id User to check.
449 * @return bool
450 */
451 private static function could_ever_show_manage( $user_id ) {
452 return $user_id
453 && class_exists( 'Jetpack' )
454 && ! is_multisite()
455 && user_can( $user_id, 'manage_options' );
456 }
457
458 /**
459 * Look a user's partner type up now, unless a fresh answer or a recent failure says not to.
460 *
461 * Also the cron callback, so a refresh already done inline is not repeated when it fires.
462 *
463 * @param int $user_id User to look up.
464 * @return void
465 */
466 public static function refresh_partner_type_if_stale( $user_id ) {
467 $user_id = (int) $user_id;
468
469 if ( self::could_ever_show_manage( $user_id ) && self::is_partner_type_stale( $user_id ) && ! self::is_backing_off( $user_id ) ) {
470 self::refresh_partner_type( $user_id );
471 }
472 }
473
474 /**
475 * Wait before asking about this user again.
476 *
477 * A lookup that produced no answer stores nothing, so without this every later caller would
478 * repeat it — once per My Jetpack page load for as long as WordPress.com is unreachable.
479 *
480 * @param int $user_id User whose lookup failed.
481 * @return void
482 */
483 private static function back_off( $user_id ) {
484 set_transient( self::retry_transient_key( $user_id ), time(), self::PARTNER_TYPE_RETRY_DELAY );
485 }
486
487 /**
488 * Whether a recent lookup for this user failed to produce an answer.
489 *
490 * @param int $user_id User to check.
491 * @return bool
492 */
493 private static function is_backing_off( $user_id ) {
494 return (bool) get_transient( self::retry_transient_key( $user_id ) );
495 }
496
497 /**
498 * The transient key backing off further lookups for a user.
499 *
500 * @param int $user_id User to key by.
501 * @return string
502 */
503 private static function retry_transient_key( $user_id ) {
504 return self::PARTNER_TYPE_RETRY_TRANSIENT_PREFIX . (int) $user_id;
505 }
506
507 /**
508 * Check whether the Automattic for Agencies banner has been dismissed on this site.
509 *
510 * The dismissal is stored per site rather than per user: whether the people running this site
511 * want an agency partnership is a property of the site, not of an individual login, so one
512 * admin dismissing the banner settles it for everyone.
513 *
514 * The trade-off is worth stating, because the rest of this payload does not work that way.
515 * `could_use_jp_manage()` and `is_agency_account()` are both computed from the *current*
516 * admin's WordPress.com account, so a second admin who would have been shown the banner
517 * cannot bring it back once someone else has dismissed it.
518 *
519 * @return bool True if the banner has been dismissed.
520 */
521 public static function is_banner_dismissed() {
522 return (bool) \Jetpack_Options::get_option( 'dismissed_a4a_banner', false );
523 }
524
525 /**
526 * Dismiss the Automattic for Agencies banner.
527 *
528 * @return WP_REST_Response
529 */
530 public static function dismiss_banner() {
531 \Jetpack_Options::update_option( 'dismissed_a4a_banner', true );
532
533 return rest_ensure_response( array( 'success' => true ) );
534 }
535
536 /**
537 * Get Jetpack Manage data for REST API.
538 *
539 * @return WP_Error|WP_REST_Response
540 */
541 public static function get_jetpack_manage_data() {
542 $is_enabled = self::could_use_jp_manage();
543 $is_agency_account = $is_enabled && self::is_agency_account_now();
544
545 return rest_ensure_response(
546 array(
547 'isEnabled' => $is_enabled,
548 'isAgencyAccount' => $is_agency_account,
549 'isDismissed' => self::is_banner_dismissed(),
550 )
551 );
552 }
553 }
554