PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.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-a.3, at jetpack_vendor/automattic/jetpack-my-jetpack/src/class-jetpack-manage.php

552 lines 16.9 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 return Admin_Menu::add_menu(
191 __( 'Jetpack Manage', 'jetpack-my-jetpack' ),
192 _x( 'Jetpack Manage', 'product name shown in menu', 'jetpack-my-jetpack' ) . ' <span aria-hidden="true">↗</span>',
193 'manage_options',
194 esc_url( Redirect::get_url( 'cloud-manage-dashboard-wp-menu', $args ) ),
195 null,
196 Admin_Menu::POSITION_EXTERNAL,
197 array( 'key' => 'jetpack-manage' )
198 );
199 }
200
201 /**
202 * Check if the user has enough sites to be able to use Jetpack Manage.
203 *
204 * @param int $min_sites Minimum number of sites to be able to use Jetpack Manage.
205 *
206 * @return bool Return true if the user has enough sites to be able to use Jetpack Manage.
207 */
208 public static function could_use_jp_manage( $min_sites = 2 ) {
209 // Only proceed if the user is connected to WordPress.com.
210 if ( ! ( new Connection_Manager() )->is_user_connected() ) {
211 return false;
212 }
213
214 // Do not display the menu if Jetpack plugin is not installed.
215 if ( ! class_exists( 'Jetpack' ) ) {
216 return false;
217 }
218
219 // Do not display the menu on Multisite.
220 if ( is_multisite() ) {
221 return false;
222 }
223
224 // Check if the user has the minimum number of sites.
225 $user_data = ( new Connection_Manager() )->get_connected_user_data( get_current_user_id() );
226 if ( ! isset( $user_data['site_count'] ) || $user_data['site_count'] < $min_sites ) {
227 return false;
228 }
229
230 return true;
231 }
232
233 /**
234 * Check if the user is a partner/agency.
235 *
236 * Answers from what the last lookup stored and never makes a request, because the sidebar
237 * asks on every admin page load. A user nobody has looked up yet reads as not an agency.
238 *
239 * @return bool Return true if the user is a partner/agency, otherwise false.
240 */
241 public static function is_agency_account() {
242 // Only proceed if the user is connected to WordPress.com.
243 if ( ! ( new Connection_Manager() )->is_user_connected() ) {
244 return false;
245 }
246
247 $stored = self::get_stored_partner_type( get_current_user_id() );
248
249 return null !== $stored && 'agency' === $stored['type'];
250 }
251
252 /**
253 * Check if the user is a partner/agency, looking them up first if the answer is stale.
254 *
255 * Only for a caller that can wait on WordPress.com, which rules out any page render.
256 *
257 * @return bool Return true if the user is a partner/agency, otherwise false.
258 */
259 private static function is_agency_account_now() {
260 self::refresh_partner_type_if_stale( get_current_user_id() );
261
262 return self::is_agency_account();
263 }
264
265 /**
266 * Schedule a partner type refresh for the user who just logged in.
267 *
268 * @param string $user_login Username, unused.
269 * @param \WP_User|null $user The user who logged in.
270 * @return void
271 */
272 public static function schedule_partner_type_refresh_on_login( $user_login, $user = null ) {
273 if ( $user instanceof \WP_User ) {
274 self::maybe_schedule_partner_type_refresh( $user->ID );
275 }
276 }
277
278 /**
279 * Queue a partner type lookup, unless a fresh answer or a pending job makes it pointless.
280 *
281 * Every check here reads options or user meta, so this stays free to call on `admin_init`.
282 *
283 * @param int|string|null $user_id User to look up. Anything falsy means the current user,
284 * which is what `admin_init` passes: `''`, not nothing.
285 * @return void
286 */
287 public static function maybe_schedule_partner_type_refresh( $user_id = null ) {
288 $user_id = $user_id ? (int) $user_id : get_current_user_id();
289
290 if ( ! self::could_ever_show_manage( $user_id ) ) {
291 return;
292 }
293
294 // Nothing to ask WordPress.com about a user it does not know.
295 if ( ! ( new Connection_Manager() )->is_user_connected( $user_id ) ) {
296 return;
297 }
298
299 if ( ! self::is_partner_type_stale( $user_id ) || self::is_backing_off( $user_id ) ) {
300 return;
301 }
302
303 $args = array( $user_id );
304 $next = wp_next_scheduled( self::PARTNER_TYPE_REFRESH_HOOK, $args );
305
306 if ( $next > time() - self::PARTNER_TYPE_OVERDUE_GRACE ) {
307 return;
308 }
309
310 // Long overdue means cron is not running it, and wp_next_scheduled() would keep reporting
311 // it forever, suppressing every later attempt.
312 if ( $next ) {
313 wp_unschedule_event( $next, self::PARTNER_TYPE_REFRESH_HOOK, $args );
314 }
315
316 wp_schedule_single_event(
317 time() + self::PARTNER_TYPE_REFRESH_DELAY + wp_rand( 0, self::PARTNER_TYPE_REFRESH_JITTER ),
318 self::PARTNER_TYPE_REFRESH_HOOK,
319 $args
320 );
321 }
322
323 /**
324 * Look a user's partner type up at WordPress.com and store it.
325 *
326 * Signs as `$user_id` explicitly rather than through `wpcom_json_api_request_as_user()`,
327 * which signs as the current user — and a cron request has none.
328 *
329 * @param int $user_id User to look up.
330 * @return void
331 */
332 public static function refresh_partner_type( $user_id ) {
333 $user_id = (int) $user_id;
334
335 $connection = new Connection_Manager();
336
337 if ( ! $user_id || ! $connection->is_user_connected( $user_id ) ) {
338 return;
339 }
340
341 // An answer that cannot be tied to an account could never be checked for a mismatch.
342 $wpcom_user_id = $connection->resolve_wpcom_user_id( $user_id );
343 if ( ! $wpcom_user_id ) {
344 self::back_off( $user_id );
345 return;
346 }
347
348 $request_args = Client::validate_args_for_wpcom_json_api_request( '/jetpack-partners', '2', array( 'method' => 'GET' ) );
349 $request_args['user_id'] = $user_id;
350
351 $wpcom_response = Client::remote_request( $request_args );
352 $response_code = (int) wp_remote_retrieve_response_code( $wpcom_response );
353
354 // Only 200 (the record) and 403 ("no partner account") settle it; storing anything else
355 // would read as "not an agency" for a day.
356 if ( is_wp_error( $wpcom_response ) || ! in_array( $response_code, array( 200, 403 ), true ) ) {
357 self::back_off( $user_id );
358 return;
359 }
360
361 $partner_data = 200 === $response_code
362 ? json_decode( wp_remote_retrieve_body( $wpcom_response ) )
363 : array();
364
365 // A 200 that did not parse is a truncated body or an error page, not an empty answer.
366 if ( ! is_array( $partner_data ) ) {
367 self::back_off( $user_id );
368 return;
369 }
370
371 // The endpoint returns a single-element array; it uses Jetpack_Partner::find_by_owner.
372 $partner_type = count( $partner_data ) === 1 && isset( $partner_data[0]->partner_type )
373 ? $partner_data[0]->partner_type
374 : self::NO_PARTNER;
375
376 delete_transient( self::retry_transient_key( $user_id ) );
377
378 update_user_meta(
379 $user_id,
380 self::PARTNER_TYPE_USER_META_KEY,
381 array(
382 'type' => $partner_type,
383 'time' => time(),
384 'wpcom_user_id' => $wpcom_user_id,
385 )
386 );
387 }
388
389 /**
390 * Drop a user's stored partner type when they disconnect from WordPress.com.
391 *
392 * @param int $user_id Disconnected user.
393 * @return void
394 */
395 public static function forget_partner_type( $user_id ) {
396 $user_id = (int) $user_id;
397
398 delete_user_meta( $user_id, self::PARTNER_TYPE_USER_META_KEY );
399 delete_transient( self::retry_transient_key( $user_id ) );
400
401 // A queued refresh would outlive the reason it was queued for.
402 wp_clear_scheduled_hook( self::PARTNER_TYPE_REFRESH_HOOK, array( $user_id ) );
403 }
404
405 /**
406 * The partner type stored for a user, unless it describes a different WordPress.com account.
407 *
408 * A binding that has gone to 0 counts as different: a token rewrite is what clears it.
409 *
410 * @param int $user_id User to read.
411 * @return array{type: string, time: int, wpcom_user_id: int}|null Null when nothing usable is stored.
412 */
413 private static function get_stored_partner_type( $user_id ) {
414 $user_id = (int) $user_id;
415 $stored = $user_id ? get_user_meta( $user_id, self::PARTNER_TYPE_USER_META_KEY, true ) : '';
416
417 if ( ! is_array( $stored ) || ! isset( $stored['type'] ) || ! isset( $stored['time'] ) ) {
418 return null;
419 }
420
421 if ( ! empty( $stored['wpcom_user_id'] ) && (int) $stored['wpcom_user_id'] !== Utils::get_wpcom_user_id( $user_id ) ) {
422 return null;
423 }
424
425 return $stored;
426 }
427
428 /**
429 * Whether a user's stored partner type is missing or old enough to ask again.
430 *
431 * @param int $user_id User to check.
432 * @return bool
433 */
434 private static function is_partner_type_stale( $user_id ) {
435 $stored = self::get_stored_partner_type( $user_id );
436
437 return null === $stored || $stored['time'] <= time() - self::PARTNER_TYPE_MAX_AGE;
438 }
439
440 /**
441 * Whether anything on this site could ever show this user the answer.
442 *
443 * The same cheap conditions the menu item and the REST payload require, minus the site count,
444 * which can itself call WordPress.com.
445 *
446 * @param int $user_id User to check.
447 * @return bool
448 */
449 private static function could_ever_show_manage( $user_id ) {
450 return $user_id
451 && class_exists( 'Jetpack' )
452 && ! is_multisite()
453 && user_can( $user_id, 'manage_options' );
454 }
455
456 /**
457 * Look a user's partner type up now, unless a fresh answer or a recent failure says not to.
458 *
459 * Also the cron callback, so a refresh already done inline is not repeated when it fires.
460 *
461 * @param int $user_id User to look up.
462 * @return void
463 */
464 public static function refresh_partner_type_if_stale( $user_id ) {
465 $user_id = (int) $user_id;
466
467 if ( self::could_ever_show_manage( $user_id ) && self::is_partner_type_stale( $user_id ) && ! self::is_backing_off( $user_id ) ) {
468 self::refresh_partner_type( $user_id );
469 }
470 }
471
472 /**
473 * Wait before asking about this user again.
474 *
475 * A lookup that produced no answer stores nothing, so without this every later caller would
476 * repeat it — once per My Jetpack page load for as long as WordPress.com is unreachable.
477 *
478 * @param int $user_id User whose lookup failed.
479 * @return void
480 */
481 private static function back_off( $user_id ) {
482 set_transient( self::retry_transient_key( $user_id ), time(), self::PARTNER_TYPE_RETRY_DELAY );
483 }
484
485 /**
486 * Whether a recent lookup for this user failed to produce an answer.
487 *
488 * @param int $user_id User to check.
489 * @return bool
490 */
491 private static function is_backing_off( $user_id ) {
492 return (bool) get_transient( self::retry_transient_key( $user_id ) );
493 }
494
495 /**
496 * The transient key backing off further lookups for a user.
497 *
498 * @param int $user_id User to key by.
499 * @return string
500 */
501 private static function retry_transient_key( $user_id ) {
502 return self::PARTNER_TYPE_RETRY_TRANSIENT_PREFIX . (int) $user_id;
503 }
504
505 /**
506 * Check whether the Automattic for Agencies banner has been dismissed on this site.
507 *
508 * The dismissal is stored per site rather than per user: whether the people running this site
509 * want an agency partnership is a property of the site, not of an individual login, so one
510 * admin dismissing the banner settles it for everyone.
511 *
512 * The trade-off is worth stating, because the rest of this payload does not work that way.
513 * `could_use_jp_manage()` and `is_agency_account()` are both computed from the *current*
514 * admin's WordPress.com account, so a second admin who would have been shown the banner
515 * cannot bring it back once someone else has dismissed it.
516 *
517 * @return bool True if the banner has been dismissed.
518 */
519 public static function is_banner_dismissed() {
520 return (bool) \Jetpack_Options::get_option( 'dismissed_a4a_banner', false );
521 }
522
523 /**
524 * Dismiss the Automattic for Agencies banner.
525 *
526 * @return WP_REST_Response
527 */
528 public static function dismiss_banner() {
529 \Jetpack_Options::update_option( 'dismissed_a4a_banner', true );
530
531 return rest_ensure_response( array( 'success' => true ) );
532 }
533
534 /**
535 * Get Jetpack Manage data for REST API.
536 *
537 * @return WP_Error|WP_REST_Response
538 */
539 public static function get_jetpack_manage_data() {
540 $is_enabled = self::could_use_jp_manage();
541 $is_agency_account = $is_enabled && self::is_agency_account_now();
542
543 return rest_ensure_response(
544 array(
545 'isEnabled' => $is_enabled,
546 'isAgencyAccount' => $is_agency_account,
547 'isDismissed' => self::is_banner_dismissed(),
548 )
549 );
550 }
551 }
552