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