Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
65.23% covered (warning)
65.23%
182 / 279
23.53% covered (danger)
23.53%
4 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
REST_Products
65.23% covered (warning)
65.23%
182 / 279
23.53% covered (danger)
23.53%
4 / 17
94.53
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
134 / 134
100.00% covered (success)
100.00%
1 / 1
1
 get_products_schema
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 get_interstitials_schema
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
2
 permissions_callback
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 view_products_permissions_callback
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 check_products_string
76.19% covered (warning)
76.19%
16 / 21
0.00% covered (danger)
0.00%
0 / 1
4.22
 check_products_argument
28.57% covered (danger)
28.57%
2 / 7
0.00% covered (danger)
0.00%
0 / 1
3.46
 get_products
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 get_products_api_data
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 get_products_by_ownership
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 edit_permissions_callback
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 activate_products
55.56% covered (warning)
55.56%
10 / 18
0.00% covered (danger)
0.00%
0 / 1
5.40
 activate_search_free
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 deactivate_products
41.18% covered (danger)
41.18%
7 / 17
0.00% covered (danger)
0.00%
0 / 1
7.26
 install_plugins
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
 get_interstitials_state
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 update_interstitials_state
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * Sets up the Products REST API endpoints.
4 *
5 * @package automattic/my-jetpack
6 */
7
8namespace Automattic\Jetpack\My_Jetpack;
9
10use WP_Error;
11use WP_REST_Request;
12use WP_REST_Response;
13use WP_REST_Server;
14
15/**
16 * Registers the REST routes for Products.
17 *
18 * @phan-constructor-used-for-side-effects
19 */
20class REST_Products {
21    /**
22     * Constructor.
23     */
24    public function __construct() {
25        register_rest_route(
26            'my-jetpack/v1',
27            'site/products',
28            array(
29                array(
30                    'methods'             => \WP_REST_Server::READABLE,
31                    'callback'            => __CLASS__ . '::get_products_api_data',
32                    'permission_callback' => __CLASS__ . '::view_products_permissions_callback',
33                    'args'                => array(
34                        'products' => array(
35                            'description'       => __( 'Comma-separated list of product slugs that should be retrieved.', 'jetpack-my-jetpack' ),
36                            'type'              => 'string',
37                            'required'          => false,
38                            'validate_callback' => __CLASS__ . '::check_products_string',
39                        ),
40                    ),
41                ),
42                'schema' => array( $this, 'get_products_schema' ),
43            )
44        );
45
46        $products_arg = array(
47            'description'       => __( 'Array of Product slugs', 'jetpack-my-jetpack' ),
48            'type'              => 'array',
49            'items'             => array(
50                'enum' => Products::get_products_slugs(),
51                'type' => 'string',
52            ),
53            'required'          => true,
54            'validate_callback' => __CLASS__ . '::check_products_argument',
55        );
56
57        register_rest_route(
58            'my-jetpack/v1',
59            'site/products/install',
60            array(
61                array(
62                    'methods'             => \WP_REST_Server::EDITABLE,
63                    'callback'            => __CLASS__ . '::install_plugins',
64                    'permission_callback' => __CLASS__ . '::edit_permissions_callback',
65                    'args'                => array(
66                        'products' => $products_arg,
67                    ),
68                ),
69            )
70        );
71
72        register_rest_route(
73            'my-jetpack/v1',
74            'site/products/activate',
75            array(
76                array(
77                    'methods'             => \WP_REST_Server::EDITABLE,
78                    'callback'            => __CLASS__ . '::activate_products',
79                    'permission_callback' => __CLASS__ . '::edit_permissions_callback',
80                    'args'                => array(
81                        'products' => $products_arg,
82                    ),
83                ),
84            )
85        );
86
87        register_rest_route(
88            'my-jetpack/v1',
89            'site/products/search/activate-free',
90            array(
91                array(
92                    'methods'             => \WP_REST_Server::EDITABLE,
93                    'callback'            => __CLASS__ . '::activate_search_free',
94                    'permission_callback' => __CLASS__ . '::edit_permissions_callback',
95                    'args'                => array(
96                        // No enum: WordPress.com records an unfamiliar source as `unknown` rather
97                        // than refusing, so a stricter list here would reject what it accepts.
98                        'source' => array(
99                            'description'       => __( 'Where the activation was requested from.', 'jetpack-my-jetpack' ),
100                            'type'              => 'string',
101                            'required'          => false,
102                            'sanitize_callback' => 'sanitize_key',
103                        ),
104                    ),
105                ),
106            )
107        );
108
109        register_rest_route(
110            'my-jetpack/v1',
111            'site/products/interstitials',
112            array(
113                array(
114                    'methods'             => WP_REST_Server::READABLE,
115                    'callback'            => array( self::class, 'get_interstitials_state' ),
116                    'permission_callback' => array( self::class, 'edit_permissions_callback' ),
117                ),
118                array(
119                    'methods'             => WP_REST_Server::EDITABLE,
120                    'callback'            => array( self::class, 'update_interstitials_state' ),
121                    'permission_callback' => array( self::class, 'edit_permissions_callback' ),
122                    'args'                => array(
123                        'products' => array(
124                            'description'          => __( 'Key-value pairs of product slugs and their interstitial states.', 'jetpack-my-jetpack' ),
125                            'type'                 => 'object',
126                            'required'             => true,
127                            'properties'           => array_fill_keys(
128                                Products::get_products_slugs(),
129                                array(
130                                    'type' => 'boolean',
131                                )
132                            ),
133                            'additionalProperties' => false,
134                            'minProperties'        => 1,
135                        ),
136                    ),
137                ),
138                'schema' => array( self::class, 'get_interstitials_schema' ),
139            )
140        );
141
142        register_rest_route(
143            'my-jetpack/v1',
144            'site/products/deactivate',
145            array(
146                array(
147                    'methods'             => \WP_REST_Server::DELETABLE,
148                    'callback'            => __CLASS__ . '::deactivate_products',
149                    'permission_callback' => __CLASS__ . '::edit_permissions_callback',
150                    'args'                => array(
151                        'products' => $products_arg,
152                    ),
153                ),
154            )
155        );
156
157        register_rest_route(
158            'my-jetpack/v1',
159            'site/products-ownership',
160            array(
161                array(
162                    'methods'             => \WP_REST_Server::READABLE,
163                    'callback'            => __CLASS__ . '::get_products_by_ownership',
164                    'permission_callback' => __CLASS__ . '::view_products_permissions_callback',
165                ),
166            )
167        );
168    }
169
170    /**
171     * Get the schema for the products endpoint
172     *
173     * @return array
174     */
175    public function get_products_schema() {
176        return array(
177            '$schema'    => 'http://json-schema.org/draft-04/schema#',
178            'title'      => 'products',
179            'type'       => 'object',
180            'properties' => Products::get_product_data_schema(),
181        );
182    }
183
184    /**
185     * Get the schema for the interstitials endpoint
186     *
187     * @return array
188     */
189    public static function get_interstitials_schema() {
190        return array(
191            '$schema'    => 'http://json-schema.org/draft-04/schema#',
192            'title'      => 'Products interstitials',
193            'type'       => 'object',
194            'properties' => array(
195                'products' => array(
196                    'type'        => 'object',
197                    'description' => __( 'Key-value pairs of product slugs and their interstitial states.', 'jetpack-my-jetpack' ),
198                    'properties'  => array_fill_keys(
199                        Products::get_products_slugs(),
200                        array(
201                            'description' => __( 'Interstitial state for the product. True means that the user has seen the interstitial for the product.', 'jetpack-my-jetpack' ),
202                            'type'        => 'boolean',
203                        )
204                    ),
205                ),
206            ),
207        );
208    }
209
210    /**
211     * Check user capability to access the endpoint.
212     *
213     * @access public
214     * @static
215     *
216     * @return true|WP_Error
217     */
218    public static function permissions_callback() {
219        return current_user_can( 'manage_options' );
220    }
221
222    /**
223     * Check if the user is permitted to view the product and product info
224     *
225     * @return bool
226     */
227    public static function view_products_permissions_callback() {
228        return current_user_can( 'edit_posts' );
229    }
230
231    /**
232     * Check Products string (comma-separated string).
233     *
234     * @access public
235     * @static
236     *
237     * @param  mixed $value - Value of the 'product' argument.
238     * @return true|WP_Error   True if the value is valid, WP_Error otherwise.
239     */
240    public static function check_products_string( $value ) {
241        if ( ! is_string( $value ) ) {
242            return new WP_Error(
243                'rest_invalid_param',
244                esc_html__( 'The product argument must be a string.', 'jetpack-my-jetpack' ),
245                array( 'status' => 400 )
246            );
247        }
248
249        $products_array = explode( ',', $value );
250        $all_products   = Products::get_products_slugs();
251
252        foreach ( $products_array as $product_slug ) {
253            if ( ! in_array( $product_slug, $all_products, true ) ) {
254                return new WP_Error(
255                    'rest_invalid_param',
256                    esc_html(
257                        sprintf(
258                            /* translators: %s is the product_slug, it should Not be translated. */
259                            __( 'The specified product argument %s is an invalid product.', 'jetpack-my-jetpack' ),
260                            $product_slug
261                        )
262                    ),
263                    array( 'status' => 400 )
264                );
265            }
266        }
267
268        return true;
269    }
270
271    /**
272     * Check Products argument.
273     *
274     * @access public
275     * @static
276     *
277     * @param  mixed $value - Value of the 'product' argument.
278     * @return true|WP_Error   True if the value is valid, WP_Error otherwise.
279     */
280    public static function check_products_argument( $value ) {
281        if ( ! is_array( $value ) ) {
282            return new WP_Error(
283                'rest_invalid_param',
284                esc_html__( 'The product argument must be an array.', 'jetpack-my-jetpack' ),
285                array( 'status' => 400 )
286            );
287        }
288
289        return true;
290    }
291
292    /**
293     * Site products endpoint.
294     *
295     * @param \WP_REST_Request $request The request object.
296     * @return WP_Error|\WP_REST_Response
297     */
298    public static function get_products( $request ) {
299        $slugs         = $request->get_param( 'products' );
300        $product_slugs = ! empty( $slugs ) ? array_map( 'trim', explode( ',', $slugs ) ) : array();
301
302        $response = Products::get_products( $product_slugs );
303        return rest_ensure_response( $response );
304    }
305
306    /**
307     * Site API product data endpoint
308     *
309     * @param \WP_REST_Request $request The request object.
310     *
311     * @return WP_Error|\WP_REST_Response
312     */
313    public static function get_products_api_data( $request ) {
314        $slugs         = $request->get_param( 'products' );
315        $product_slugs = ! empty( $slugs ) ? array_map( 'trim', explode( ',', $slugs ) ) : array();
316
317        $response = Products::get_products_api_data( $product_slugs );
318        return rest_ensure_response( $response );
319    }
320
321    /**
322     * Site products endpoint.
323     *
324     * @return \WP_REST_Response of site products list.
325     */
326    public static function get_products_by_ownership() {
327        $response = array(
328            'unownedProducts' => Products::get_products_by_ownership( 'unowned' ),
329            'ownedProducts'   => Products::get_products_by_ownership( 'owned' ),
330        );
331        return rest_ensure_response( $response );
332    }
333
334    /**
335     * Check permission to edit product
336     *
337     * @return bool
338     */
339    public static function edit_permissions_callback() {
340        if ( ! current_user_can( 'activate_plugins' ) ) {
341            return false;
342        }
343        if ( is_multisite() && ! current_user_can( 'manage_network' ) ) {
344            return false;
345        }
346        return true;
347    }
348
349    /**
350     * Callback for activating products
351     *
352     * @param \WP_REST_Request $request The request object.
353     * @return \WP_REST_Response|\WP_Error
354     */
355    public static function activate_products( $request ) {
356        $products_array = $request->get_param( 'products' );
357
358        foreach ( $products_array as $product_slug ) {
359            $product = Products::get_product( $product_slug );
360            if ( ! isset( $product['class'] ) ) {
361                return new \WP_Error(
362                    'product_class_handler_not_found',
363                    sprintf(
364                        /* translators: %s is the product_slug */
365                        __( 'The product slug %s does not have an associated class handler.', 'jetpack-my-jetpack' ),
366                        $product_slug
367                    ),
368                    array( 'status' => 501 )
369                );
370            }
371
372            $activate_product_result = call_user_func( array( $product['class'], 'activate' ) );
373            if ( is_wp_error( $activate_product_result ) ) {
374                $activate_product_result->add_data( array( 'status' => 400 ) );
375                return $activate_product_result;
376            }
377        }
378        set_transient( 'my_jetpack_product_activated', implode( ',', $products_array ), 10 );
379
380        return rest_ensure_response( Products::get_products( $products_array ) );
381    }
382
383    /**
384     * Grant the free Search product to this site, replacing the $0 checkout round trip.
385     *
386     * POST `my-jetpack/v1/site/products/search/activate-free`
387     *
388     * @since 6.8.0
389     *
390     * @param WP_REST_Request $request The request.
391     * @return WP_REST_Response|WP_Error Errors carry `checkout_fallback`; see {@see Products\Search::activate_free_product()}.
392     */
393    public static function activate_search_free( $request ) {
394        $result = Products\Search::activate_free_product( $request->get_param( 'source' ) );
395        if ( is_wp_error( $result ) ) {
396            return $result;
397        }
398
399        return rest_ensure_response( $result );
400    }
401
402    /**
403     * Callback for deactivating products
404     *
405     * @param \WP_REST_Request $request The request object.
406     * @return \WP_REST_Response|\WP_Error
407     */
408    public static function deactivate_products( $request ) {
409        $products_array = $request->get_param( 'products' );
410
411        foreach ( $products_array as $product_slug ) {
412            $product = Products::get_product( $product_slug );
413            if ( ! isset( $product['class'] ) ) {
414                return new \WP_Error(
415                    'product_class_handler_not_found',
416                    sprintf(
417                        /* translators: %s is the product_slug */
418                        __( 'The product slug %s does not have an associated class handler.', 'jetpack-my-jetpack' ),
419                        $product_slug
420                    ),
421                    array( 'status' => 501 )
422                );
423            }
424
425            $deactivate_product_result = call_user_func( array( $product['class'], 'deactivate' ) );
426            if ( is_wp_error( $deactivate_product_result ) ) {
427                $deactivate_product_result->add_data( array( 'status' => 400 ) );
428                return $deactivate_product_result;
429            }
430        }
431
432        return rest_ensure_response( Products::get_products( $products_array ) );
433    }
434
435    /**
436     * Callback for installing (and activating) multiple product plugins.
437     *
438     * @param \WP_REST_Request $request The request object.
439     * @return \WP_REST_Response|\WP_Error
440     */
441    public static function install_plugins( $request ) {
442        $products_array = $request->get_param( 'products' );
443
444        foreach ( $products_array as $product_slug ) {
445            $product = Products::get_product( $product_slug );
446            if ( ! isset( $product['class'] ) ) {
447                return new \WP_Error(
448                    'product_class_handler_not_found',
449                    sprintf(
450                        /* translators: %s is the product_slug */
451                        __( 'The product slug %s does not have an associated class handler.', 'jetpack-my-jetpack' ),
452                        $product_slug
453                    ),
454                    array( 'status' => 501 )
455                );
456            }
457
458            $install_product_result = call_user_func( array( $product['class'], 'install_and_activate_standalone' ) );
459            if ( is_wp_error( $install_product_result ) ) {
460                $install_product_result->add_data( array( 'status' => 400 ) );
461                return $install_product_result;
462            }
463        }
464
465        return rest_ensure_response( Products::get_products( $products_array ) );
466    }
467
468    /**
469     * Get interstitials state for the products
470     *
471     * @return WP_REST_Response
472     */
473    public static function get_interstitials_state() {
474
475        return rest_ensure_response(
476            array(
477                'products' => Products::get_interstitials_state(),
478            )
479        );
480    }
481
482    /**
483     * Update interstitials state for the products
484     *
485     * @param WP_REST_Request $request The request object.
486     * @return WP_REST_Response|WP_Error
487     */
488    public static function update_interstitials_state( WP_REST_Request $request ) {
489
490        $success = Products::update_interstitials_state( $request->get_param( 'products' ) );
491
492        if ( ! $success ) {
493            return new WP_Error(
494                'my_jetpack_interstitials_update_error',
495                __( 'Failed to update interstitials state.', 'jetpack-my-jetpack' ),
496                array( 'status' => 500 )
497            );
498        }
499
500        return rest_ensure_response(
501            array(
502                'products' => Products::get_interstitials_state(),
503            )
504        );
505    }
506}