Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.53% covered (success)
93.53%
188 / 201
66.67% covered (warning)
66.67%
8 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
REST_Main_Features
93.53% covered (success)
93.53%
188 / 201
66.67% covered (warning)
66.67%
8 / 12
70.29
0.00% covered (danger)
0.00%
0 / 1
 register_rest_routes
100.00% covered (success)
100.00%
68 / 68
100.00% covered (success)
100.00%
1 / 1
2
 permissions_callback
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%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 get_state
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 switch_plugin
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
9.02
 switch_many
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
12
 switch_module
72.22% covered (warning)
72.22%
13 / 18
0.00% covered (danger)
0.00%
0 / 1
16.62
 refuse_install
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 explain_install_error
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 refuse_deactivation
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 run
78.57% covered (warning)
78.57%
22 / 28
0.00% covered (danger)
0.00%
0 / 1
17.21
1<?php
2/**
3 * REST route that installs and switches the plugins behind the Features tab.
4 *
5 * @package automattic/my-jetpack
6 */
7
8namespace Automattic\Jetpack\My_Jetpack;
9
10use Automattic\Jetpack\Modules;
11use Automattic\Jetpack\Plugins_Installer;
12use WP_Error;
13use WP_REST_Request;
14use WP_REST_Server;
15
16/**
17 * Installs, activates and deactivates the plugins and modules in the feature map.
18 */
19class REST_Main_Features {
20
21    /**
22     * The namespace these routes live in.
23     *
24     * `wpcom/v2` rather than `my-jetpack/v1`, which WordPress.com Simple does not serve —
25     * and the Features tab runs there too.
26     */
27    const ROUTE_NAMESPACE = 'wpcom/v2';
28
29    /**
30     * User meta that keeps the Features tab banner hidden for the user who dismissed it.
31     */
32    const BANNER_DISMISSED_META = 'jetpack_my_jetpack_features_banner_dismissed';
33
34    /**
35     * Register the route.
36     *
37     * @return void
38     */
39    public function register_rest_routes() {
40        register_rest_route(
41            self::ROUTE_NAMESPACE,
42            'my-jetpack/site/features',
43            array(
44                'methods'             => WP_REST_Server::READABLE,
45                'callback'            => __CLASS__ . '::get_state',
46                'permission_callback' => __CLASS__ . '::permissions_callback',
47            )
48        );
49
50        if ( ! Initializer::is_offline_features_enabled() ) {
51            register_rest_route(
52                self::ROUTE_NAMESPACE,
53                'my-jetpack/site/features/banner/dismiss',
54                array(
55                    'methods'             => WP_REST_Server::CREATABLE,
56                    'callback'            => __CLASS__ . '::dismiss_banner',
57                    // Anyone who can see My Jetpack sees the banner, so anyone who can see it may dismiss it.
58                    'permission_callback' => array( Initializer::class, 'permissions_callback' ),
59                )
60            );
61        }
62
63        register_rest_route(
64            self::ROUTE_NAMESPACE,
65            'my-jetpack/site/features/plugin',
66            array(
67                'methods'             => WP_REST_Server::CREATABLE,
68                'callback'            => __CLASS__ . '::switch_plugin',
69                'permission_callback' => __CLASS__ . '::permissions_callback',
70                'args'                => array(
71                    'plugin' => array(
72                        'type'     => 'string',
73                        'required' => true,
74                        // Only plugins the feature map names, never an arbitrary slug.
75                        'enum'     => Main_Features::get_switchable_plugins(),
76                    ),
77                    'action' => array(
78                        'type'     => 'string',
79                        'required' => true,
80                        'enum'     => array( 'install', 'activate', 'deactivate' ),
81                    ),
82                ),
83            )
84        );
85
86        register_rest_route(
87            self::ROUTE_NAMESPACE,
88            'my-jetpack/site/features/bulk',
89            array(
90                'methods'             => WP_REST_Server::CREATABLE,
91                'callback'            => __CLASS__ . '::switch_many',
92                'permission_callback' => __CLASS__ . '::permissions_callback',
93                'args'                => array(
94                    'active'  => array(
95                        'type'     => 'boolean',
96                        'required' => true,
97                    ),
98                    'plugins' => array(
99                        'type'    => 'array',
100                        'default' => array(),
101                        'items'   => array(
102                            'type' => 'string',
103                            'enum' => Main_Features::get_switchable_plugins(),
104                        ),
105                    ),
106                    'modules' => array(
107                        'type'    => 'array',
108                        'default' => array(),
109                        'items'   => array( 'type' => 'string' ),
110                    ),
111                ),
112            )
113        );
114    }
115
116    /**
117     * Whoever may activate plugins here may use the route, as with the products routes.
118     *
119     * @return bool
120     */
121    public static function permissions_callback() {
122        return REST_Products::edit_permissions_callback();
123    }
124
125    /**
126     * Whether the current user has dismissed the Features tab banner.
127     *
128     * @return bool
129     */
130    public static function is_banner_dismissed() {
131        return (bool) get_user_meta( get_current_user_id(), self::BANNER_DISMISSED_META, true );
132    }
133
134    /**
135     * Hide the Features tab banner for the current user, for good.
136     *
137     * @return \WP_REST_Response|WP_Error
138     */
139    public static function dismiss_banner() {
140        // Checked first: update_user_meta() also returns false when the value is unchanged.
141        if ( ! self::is_banner_dismissed() && ! update_user_meta( get_current_user_id(), self::BANNER_DISMISSED_META, 1 ) ) {
142            return new WP_Error( 'banner_not_dismissed', __( 'The banner could not be dismissed.', 'jetpack-my-jetpack' ), array( 'status' => 500 ) );
143        }
144
145        return rest_ensure_response( true );
146    }
147
148    /**
149     * The Features tab's state, read fresh from the site.
150     *
151     * The page is rendered with a copy of this, but that copy ages: it is a snapshot from
152     * the load, and anything switched since — here or anywhere else — has moved past it.
153     *
154     * @return \WP_REST_Response
155     */
156    public static function get_state() {
157        return rest_ensure_response( Main_Features::get_state( Initializer::is_offline_features_enabled() ) );
158    }
159
160    /**
161     * Install, activate or deactivate one plugin from the feature map.
162     *
163     * @param WP_REST_Request $request The request.
164     * @return \WP_REST_Response|WP_Error The Features tab's fresh state, or an error.
165     */
166    public static function switch_plugin( $request ) {
167        $slug   = $request->get_param( 'plugin' );
168        $action = $request->get_param( 'action' );
169
170        $refused = self::refuse_deactivation( $slug, $action );
171        if ( $refused ) {
172            return $refused;
173        }
174
175        $refused = 'install' === $action ? self::refuse_install() : null;
176        if ( $refused ) {
177            return $refused;
178        }
179
180        $result = self::run( $slug, $action );
181
182        if ( is_wp_error( $result ) && 'install' === $action ) {
183            $result = self::explain_install_error( $result );
184        }
185
186        if ( is_wp_error( $result ) ) {
187            $data = $result->get_error_data();
188
189            if ( ! is_array( $data ) || ! isset( $data['status'] ) ) {
190                $result->add_data( array( 'status' => 400 ), $result->get_error_code() );
191            }
192
193            return $result;
194        }
195
196        return rest_ensure_response( Main_Features::get_state( Initializer::is_offline_features_enabled() ) );
197    }
198
199    /**
200     * Switch several plugins and modules on or off in one request.
201     *
202     * Each is tried in turn and a failure does not stop the rest, so the response carries the
203     * fresh state together with what could not be switched and why.
204     *
205     * @param WP_REST_Request $request The request.
206     * @return \WP_REST_Response The Features tab's fresh state, and a list of failures.
207     */
208    public static function switch_many( $request ) {
209        $active = (bool) $request->get_param( 'active' );
210        $action = $active ? 'activate' : 'deactivate';
211        $failed = array();
212
213        // Jetpack carries My Jetpack and is never switched off here, so while it is active no
214        // batch can take this page with it. Without it, any plugin in the batch might be the last
215        // carrier, and checking them one by one lets a batch switch off every carrier in turn.
216        $plugins_refused = ! $active && Main_Features::PLUGIN_ACTIVE !== Main_Features::get_plugin_status( Product::JETPACK_PLUGIN_SLUG )
217            ? new WP_Error( 'not_allowed', __( 'Plugins can only be deactivated together while the Jetpack plugin is active. Deactivate them one at a time instead.', 'jetpack-my-jetpack' ) )
218            : null;
219
220        foreach ( array_unique( (array) $request->get_param( 'plugins' ) ) as $slug ) {
221            $result = $plugins_refused ? $plugins_refused : self::refuse_deactivation( $slug, $action );
222            $result = $result ? $result : self::run( $slug, $action );
223
224            if ( is_wp_error( $result ) ) {
225                $failed[] = array(
226                    'type'    => 'plugin',
227                    'slug'    => $slug,
228                    'message' => $result->get_error_message(),
229                );
230            }
231        }
232
233        $modules_refused = current_user_can( 'jetpack_manage_modules' )
234            ? null
235            : new WP_Error( 'not_allowed', __( 'You are not allowed to manage Jetpack modules on this site.', 'jetpack-my-jetpack' ) );
236
237        foreach ( array_unique( (array) $request->get_param( 'modules' ) ) as $slug ) {
238            $result = $modules_refused ? $modules_refused : self::switch_module( $slug, $active );
239
240            if ( is_wp_error( $result ) ) {
241                $failed[] = array(
242                    'type'    => 'module',
243                    'slug'    => $slug,
244                    'message' => $result->get_error_message(),
245                );
246            }
247        }
248
249        return rest_ensure_response(
250            array(
251                'state'  => Main_Features::get_state( Initializer::is_offline_features_enabled() ),
252                'failed' => $failed,
253            )
254        );
255    }
256
257    /**
258     * Switch one Jetpack module, with the checks Jetpack's own module route makes.
259     *
260     * @param string $slug   Module slug.
261     * @param bool   $active Whether to switch it on.
262     * @return true|WP_Error
263     */
264    private static function switch_module( $slug, $active ) {
265        $modules = new Modules();
266
267        // Not is_module(): with no modules on offer, its allow-list is skipped and any slug passes.
268        if ( ! in_array( $slug, (array) $modules->get_available(), true ) ) {
269            return new WP_Error( 'not_found', __( 'That Jetpack module was not found.', 'jetpack-my-jetpack' ) );
270        }
271
272        // Already where it was asked to be: nothing to do, as with plugins above.
273        if ( $modules->is_active( $slug ) === $active ) {
274            return true;
275        }
276
277        if ( $active && Initializer::is_offline_features_enabled()
278            && ! Main_Features::module_works_locally( $slug )
279        ) {
280            return new WP_Error( 'switch_failed', __( 'Could not be switched on: unavailable in Offline mode.', 'jetpack-my-jetpack' ) );
281        }
282
283        // Gotcha: activate() still redirects and exits when a legacy plugin it replaces (such as
284        // stats/stats.php) is active, which ends this request, as it does Jetpack's own route.
285        $switched = $active ? $modules->activate( $slug, false, false ) : $modules->deactivate( $slug );
286
287        // Saved, but a jetpack_active_modules callback can still hold it where it was.
288        if ( ( $switched || ! $active ) && $modules->is_active( $slug ) !== $active ) {
289            return $active
290                ? new WP_Error( 'module_forced', __( 'Stays off: disabled by your host or site administrator.', 'jetpack-my-jetpack' ) )
291                : new WP_Error( 'module_forced', __( 'Stays on: enabled by your host or site administrator.', 'jetpack-my-jetpack' ) );
292        }
293
294        // Read after a feature name, alone or in a list; a retry rarely helps, so none is offered.
295        if ( ! $switched ) {
296            return $active
297                ? new WP_Error( 'switch_failed', __( 'Could not be switched on. It may need a Jetpack connection, or a plan that includes it.', 'jetpack-my-jetpack' ) )
298                : new WP_Error( 'switch_failed', __( 'Could not be switched off.', 'jetpack-my-jetpack' ) );
299        }
300
301        return true;
302    }
303
304    /**
305     * Refuse an install the current user can't make, saying whether the site or their role is why.
306     *
307     * @return WP_Error|null The refusal, or null when the install may go ahead.
308     */
309    private static function refuse_install() {
310        switch ( Main_Features::get_install_access() ) {
311            case Main_Features::INSTALLS_DISABLED:
312                return new WP_Error(
313                    'install_disabled',
314                    __( 'Plugin installs are turned off on this site. Ask your host or site administrator to install it.', 'jetpack-my-jetpack' ),
315                    array( 'status' => 403 )
316                );
317
318            case Main_Features::INSTALLS_NOT_PERMITTED:
319                return new WP_Error(
320                    'not_allowed',
321                    __( 'Your account can’t install plugins on this site. Ask a site administrator to install it.', 'jetpack-my-jetpack' ),
322                    array( 'status' => 403 )
323                );
324
325            default:
326                return null;
327        }
328    }
329
330    /**
331     * Say what to do about an install that failed, where the installer's own message doesn't.
332     *
333     * @param WP_Error $error What the installer returned.
334     * @return WP_Error The same error, reworded when it is a download or filesystem failure.
335     */
336    private static function explain_install_error( WP_Error $error ) {
337        $code = (string) $error->get_error_code();
338
339        // Plugins_Installer reports a failed download as no_package.
340        if ( in_array( $code, array( 'no_package', 'download_failed' ), true ) ) {
341            $message = __( 'The plugin could not be downloaded from WordPress.org. Check that your site can reach WordPress.org, then try again.', 'jetpack-my-jetpack' );
342        } elseif ( preg_match( '/^(fs_|mkdir_failed|copy_failed)/', $code ) ) {
343            $message = __( 'WordPress could not write to this site’s plugins folder. Install it from the Plugins screen instead, or ask your host for help.', 'jetpack-my-jetpack' );
344        } else {
345            return $error;
346        }
347
348        return new WP_Error( $code, $message, array( 'status' => 400 ) );
349    }
350
351    /**
352     * Refuse to switch off Jetpack, or the plugin serving this page when nothing else carries My Jetpack.
353     *
354     * @param string $slug   WordPress.org plugin slug.
355     * @param string $action One of install, activate or deactivate.
356     * @return WP_Error|null The refusal, or null when the action may go ahead.
357     */
358    private static function refuse_deactivation( $slug, $action ) {
359        if ( 'deactivate' !== $action ) {
360            return null;
361        }
362
363        // The autoloader picks one of the active carriers, so the one serving this request is
364        // refused only when nothing else could take over on the next load.
365        if ( Product::JETPACK_PLUGIN_SLUG === $slug
366            || ( Main_Features::is_hosting_plugin( $slug )
367                && Main_Features::is_only_my_jetpack_provider( Main_Features::get_hosting_plugin_slug() ) ) ) {
368            return new WP_Error(
369                'not_allowed',
370                __( 'This plugin runs the page you are on, so it cannot be deactivated from here.', 'jetpack-my-jetpack' ),
371                array( 'status' => 400 )
372            );
373        }
374
375        return null;
376    }
377
378    /**
379     * Carry out the action on the plugin, through the product that owns it where there is one.
380     *
381     * @param string $slug   WordPress.org plugin slug.
382     * @param string $action One of install, activate or deactivate.
383     * @return true|WP_Error
384     */
385    private static function run( $slug, $action ) {
386        if ( ! function_exists( 'activate_plugin' ) ) {
387            require_once ABSPATH . 'wp-admin/includes/plugin.php';
388        }
389
390        $product_class = Main_Features::get_product_class_for_plugin( $slug );
391        $file          = Main_Features::get_plugin_file( $slug, $product_class );
392
393        if ( 'deactivate' === $action ) {
394            if ( ! $file ) {
395                return new WP_Error( 'not_installed', __( 'That plugin is not installed.', 'jetpack-my-jetpack' ) );
396            }
397
398            // The product knows what else it switched on, such as the Jetpack module behind it.
399            if ( $product_class ) {
400                $deactivated = $product_class::deactivate();
401                return is_wp_error( $deactivated ) ? $deactivated : true;
402            }
403
404            deactivate_plugins( $file );
405            return true;
406        }
407
408        // Already on: nothing to do, and re-running the product's activation step would
409        // reset what it set the first time — Boost's jb_get_started, Search's Instant
410        // Search. Reachable whenever a request is retried.
411        if ( $file && Main_Features::PLUGIN_ACTIVE === Main_Features::get_plugin_status( $slug, $product_class ) ) {
412            return true;
413        }
414
415        // Installing what is already here would put a second copy beside it, which is what
416        // a plugin in a -dev folder looks like to a lookup by slug.
417        if ( 'install' === $action && ! $file ) {
418            $installed = Plugins_Installer::install_and_activate_plugin( $slug );
419
420            if ( is_wp_error( $installed ) ) {
421                return $installed;
422            }
423        } else {
424            if ( ! $file ) {
425                return new WP_Error( 'not_installed', __( 'That plugin is not installed.', 'jetpack-my-jetpack' ) );
426            }
427
428            $activated = activate_plugin( $file );
429
430            if ( is_wp_error( $activated ) ) {
431                return $activated;
432            }
433        }
434
435        // A plugin alone is not the whole product: Search still has to switch Instant Search
436        // on, Boost to mark itself started, and the Hybrid products to enable their module.
437        // A refusal is not fatal here — the plugin is on either way, so failing the request
438        // would contradict the state it returns and invite a retry of what already happened.
439        if ( $product_class ) {
440            if ( is_subclass_of( $product_class, Hybrid_Product::class ) ) {
441                $product_class::do_product_specific_activation( true, Initializer::is_offline_features_enabled() );
442            } else {
443                $product_class::do_product_specific_activation( true );
444            }
445        }
446
447        return true;
448    }
449}