Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.28% covered (success)
97.28%
143 / 147
85.71% covered (warning)
85.71%
18 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_Manage
97.28% covered (success)
97.28%
143 / 147
85.71% covered (warning)
85.71%
18 / 21
63
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 register_rest_endpoints
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
1
 permissions_callback
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 add_submenu_jetpack
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
5.00
 could_use_jp_manage
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
6.29
 is_agency_account
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 is_agency_account_now
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 schedule_partner_type_refresh_on_login
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 maybe_schedule_partner_type_refresh
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
8
 refresh_partner_type
97.06% covered (success)
97.06%
33 / 34
0.00% covered (danger)
0.00%
0 / 1
10
 forget_partner_type
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 get_stored_partner_type
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
7
 is_partner_type_stale
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 could_ever_show_manage
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 refresh_partner_type_if_stale
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 back_off
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_backing_off
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 retry_transient_key
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_banner_dismissed
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 dismiss_banner
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_jetpack_manage_data
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
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
10namespace Automattic\Jetpack\My_Jetpack;
11
12use Automattic\Jetpack\Admin_UI\Admin_Menu;
13use Automattic\Jetpack\Connection\Client;
14use Automattic\Jetpack\Connection\Manager as Connection_Manager;
15use Automattic\Jetpack\Connection\Utils;
16use Automattic\Jetpack\Redirect;
17use WP_Error;
18use WP_Rest_Response;
19
20/**
21 * Jetpack Manage features in My Jetpack.
22 */
23class 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}