Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.22% covered (warning)
72.22%
52 / 72
77.78% covered (warning)
77.78%
7 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_Manage
72.22% covered (warning)
72.22%
52 / 72
77.78% covered (warning)
77.78%
7 / 9
38.40
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
1 / 1
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
14.29% covered (danger)
14.29%
2 / 14
0.00% covered (danger)
0.00%
0 / 1
8.67
 could_use_jp_manage
20.00% covered (danger)
20.00%
2 / 10
0.00% covered (danger)
0.00%
0 / 1
24.43
 is_agency_account
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
10
 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
1
1<?php
2/**
3 * Tools to manage things related to "Jetpack Manage"
4 * - Add Jetpack Manage menu item.
5 * - Check if user is an agency (used by the Jetpack Manage 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\Redirect;
16use WP_Error;
17use WP_Rest_Response;
18
19/**
20 * Jetpack Manage features in My Jetpack.
21 */
22class Jetpack_Manage {
23    /**
24     * Transient holding the partner type this site's owner has, as answered by WordPress.com.
25     *
26     * @var string
27     */
28    const PARTNER_TYPE_TRANSIENT_KEY = 'jetpack_partner_type';
29
30    /**
31     * Cached partner type when the lookup found that this site's owner has no partner account.
32     *
33     * `get_transient()` returns `false` for a miss, so "no partner" needs a value of its own to be
34     * distinguishable from "not looked up yet".
35     *
36     * @var string
37     */
38    private const NO_PARTNER = 'none';
39
40    /**
41     * Initialize the class and hooks needed.
42     */
43    public static function init() {
44        add_action( 'admin_menu', array( self::class, 'add_submenu_jetpack' ) );
45    }
46
47    /**
48     * Register the REST API routes.
49     *
50     * @return void
51     */
52    public static function register_rest_endpoints() {
53        register_rest_route(
54            'my-jetpack/v1',
55            'jetpack-manage/data',
56            array(
57                'methods'             => \WP_REST_Server::READABLE,
58                'callback'            => __CLASS__ . '::get_jetpack_manage_data',
59                'permission_callback' => __CLASS__ . '::permissions_callback',
60            )
61        );
62
63        register_rest_route(
64            'my-jetpack/v1',
65            'jetpack-manage/dismiss-banner',
66            array(
67                'methods'             => \WP_REST_Server::EDITABLE,
68                'callback'            => __CLASS__ . '::dismiss_banner',
69                'permission_callback' => __CLASS__ . '::permissions_callback',
70            )
71        );
72    }
73
74    /**
75     * Check user capabilities to access historically active modules.
76     *
77     * @access public
78     * @static
79     *
80     * @return true|WP_Error
81     */
82    public static function permissions_callback() {
83        return current_user_can( 'manage_options' );
84    }
85
86    /**
87     * The page to be added to submenu
88     *
89     * @return void|null|string The resulting page's hook_suffix
90     */
91    public static function add_submenu_jetpack() {
92        // Do not display the menu if the user has < 2 sites.
93        if ( ! self::could_use_jp_manage( 2 ) ) {
94            return;
95        }
96
97        $args = array();
98
99        $blog_id = Connection_Manager::get_site_id( true );
100        if ( $blog_id ) {
101            $args = array( 'site' => $blog_id );
102        }
103
104        return Admin_Menu::add_menu(
105            __( 'Jetpack Manage', 'jetpack-my-jetpack' ),
106            _x( 'Jetpack Manage', 'product name shown in menu', 'jetpack-my-jetpack' ) . ' <span aria-hidden="true">↗</span>',
107            'manage_options',
108            esc_url( Redirect::get_url( 'cloud-manage-dashboard-wp-menu', $args ) ),
109            null,
110            100
111        );
112    }
113
114    /**
115     * Check if the user has enough sites to be able to use Jetpack Manage.
116     *
117     * @param int $min_sites Minimum number of sites to be able to use Jetpack Manage.
118     *
119     * @return bool Return true if the user has enough sites to be able to use Jetpack Manage.
120     */
121    public static function could_use_jp_manage( $min_sites = 2 ) {
122        // Only proceed if the user is connected to WordPress.com.
123        if ( ! ( new Connection_Manager() )->is_user_connected() ) {
124            return false;
125        }
126
127        // Do not display the menu if Jetpack plugin is not installed.
128        if ( ! class_exists( 'Jetpack' ) ) {
129            return false;
130        }
131
132        // Do not display the menu on Multisite.
133        if ( is_multisite() ) {
134            return false;
135        }
136
137        // Check if the user has the minimum number of sites.
138        $user_data = ( new Connection_Manager() )->get_connected_user_data( get_current_user_id() );
139        if ( ! isset( $user_data['site_count'] ) || $user_data['site_count'] < $min_sites ) {
140            return false;
141        }
142
143        return true;
144    }
145
146    /**
147     * Check if the user is a partner/agency.
148     *
149     * @return bool Return true if the user is a partner/agency, otherwise false.
150     */
151    public static function is_agency_account() {
152        // Only proceed if the user is connected to WordPress.com.
153        if ( ! ( new Connection_Manager() )->is_user_connected() ) {
154            return false;
155        }
156
157        // Get the cached partner type.
158        $partner_type = get_transient( self::PARTNER_TYPE_TRANSIENT_KEY );
159
160        if ( false === $partner_type ) {
161            $wpcom_response = Client::wpcom_json_api_request_as_user( '/jetpack-partners' );
162            $response_code  = (int) wp_remote_retrieve_response_code( $wpcom_response );
163
164            // A network failure or a server-side error is not an answer about this site, so leave
165            // the cache empty and ask again next time.
166            if ( is_wp_error( $wpcom_response ) || 0 === $response_code || $response_code >= 500 ) {
167                return false;
168            }
169
170            $partner_data = 200 === $response_code
171                ? json_decode( wp_remote_retrieve_body( $wpcom_response ) )
172                : null;
173
174            // The endpoint returns a single-element array (it uses Jetpack_Partner::find_by_owner),
175            // and answers 403 for a user with no partner account — which is most of them. "No
176            // partner" is a real answer and gets cached like any other; without that, those sites
177            // repeat this request on every page load that asks.
178            $partner_type = is_array( $partner_data ) && count( $partner_data ) === 1 && isset( $partner_data[0]->partner_type )
179                ? $partner_data[0]->partner_type
180                : self::NO_PARTNER;
181
182            // Cache the partner type for 1 hour.
183            set_transient( self::PARTNER_TYPE_TRANSIENT_KEY, $partner_type, HOUR_IN_SECONDS );
184        }
185
186        return 'agency' === $partner_type;
187    }
188
189    /**
190     * Check whether the Automattic for Agencies banner has been dismissed on this site.
191     *
192     * The dismissal is stored per site rather than per user: whether the people running this site
193     * want an agency partnership is a property of the site, not of an individual login, so one
194     * admin dismissing the banner settles it for everyone.
195     *
196     * The trade-off is worth stating, because the rest of this payload does not work that way.
197     * `could_use_jp_manage()` and `is_agency_account()` are both computed from the *current*
198     * admin's WordPress.com account, so a second admin who would have been shown the banner
199     * cannot bring it back once someone else has dismissed it.
200     *
201     * @return bool True if the banner has been dismissed.
202     */
203    public static function is_banner_dismissed() {
204        return (bool) \Jetpack_Options::get_option( 'dismissed_a4a_banner', false );
205    }
206
207    /**
208     * Dismiss the Automattic for Agencies banner.
209     *
210     * @return WP_REST_Response
211     */
212    public static function dismiss_banner() {
213        \Jetpack_Options::update_option( 'dismissed_a4a_banner', true );
214
215        return rest_ensure_response( array( 'success' => true ) );
216    }
217
218    /**
219     * Get Jetpack Manage data for REST API.
220     *
221     * @return WP_Error|WP_REST_Response
222     */
223    public static function get_jetpack_manage_data() {
224        $is_enabled        = self::could_use_jp_manage();
225        $is_agency_account = self::is_agency_account();
226
227        return rest_ensure_response(
228            array(
229                'isEnabled'       => $is_enabled,
230                'isAgencyAccount' => $is_agency_account,
231                'isDismissed'     => self::is_banner_dismissed(),
232            )
233        );
234    }
235}