Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.20% covered (success)
93.20%
192 / 206
64.29% covered (warning)
64.29%
9 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
REST_Main_Features
93.20% covered (success)
93.20%
192 / 206
64.29% covered (warning)
64.29%
9 / 14
78.86
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
 bulk_permissions_callback
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 is_jetpack_active
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%
34 / 34
100.00% covered (success)
100.00%
1 / 1
15
 switch_module
72.22% covered (warning)
72.22%
13 / 18
0.00% covered (danger)
0.00%
0 / 1
16.62
 refuse_install
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
4.05
 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
82.76% covered (warning)
82.76%
24 / 29
0.00% covered (danger)
0.00%
0 / 1
17.31
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__ . '::bulk_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     * Whoever may switch plugins, or modules while Jetpack is active, may send a batch; each row is then checked on its own.
127     *
128     * Without Jetpack, nothing else lets a module manager who cannot switch plugins switch a module.
129     *
130     * @return bool
131     */
132    public static function bulk_permissions_callback() {
133        return self::permissions_callback() || ( self::is_jetpack_active() && current_user_can( 'jetpack_manage_modules' ) );
134    }
135
136    /**
137     * Whether the Jetpack plugin is active here, network activation included.
138     *
139     * @return bool
140     */
141    private static function is_jetpack_active() {
142        return Main_Features::PLUGIN_ACTIVE === Main_Features::get_plugin_status( Product::JETPACK_PLUGIN_SLUG );
143    }
144
145    /**
146     * Whether the current user has dismissed the Features tab banner.
147     *
148     * @return bool
149     */
150    public static function is_banner_dismissed() {
151        return (bool) get_user_meta( get_current_user_id(), self::BANNER_DISMISSED_META, true );
152    }
153
154    /**
155     * Hide the Features tab banner for the current user, for good.
156     *
157     * @return \WP_REST_Response|WP_Error
158     */
159    public static function dismiss_banner() {
160        // Checked first: update_user_meta() also returns false when the value is unchanged.
161        if ( ! self::is_banner_dismissed() && ! update_user_meta( get_current_user_id(), self::BANNER_DISMISSED_META, 1 ) ) {
162            return new WP_Error( 'banner_not_dismissed', __( 'The banner could not be dismissed.', 'jetpack-my-jetpack' ), array( 'status' => 500 ) );
163        }
164
165        return rest_ensure_response( true );
166    }
167
168    /**
169     * The Features tab's state, read fresh from the site.
170     *
171     * The page is rendered with a copy of this, but that copy ages: it is a snapshot from
172     * the load, and anything switched since — here or anywhere else — has moved past it.
173     *
174     * @return \WP_REST_Response
175     */
176    public static function get_state() {
177        return rest_ensure_response( Main_Features::get_state( Initializer::is_offline_features_enabled() ) );
178    }
179
180    /**
181     * Install, activate or deactivate one plugin from the feature map.
182     *
183     * @param WP_REST_Request $request The request.
184     * @return \WP_REST_Response|WP_Error The Features tab's fresh state, or an error.
185     */
186    public static function switch_plugin( $request ) {
187        $slug   = $request->get_param( 'plugin' );
188        $action = $request->get_param( 'action' );
189
190        $refused = self::refuse_deactivation( $slug, $action );
191        if ( $refused ) {
192            return $refused;
193        }
194
195        $refused = 'install' === $action ? self::refuse_install() : null;
196        if ( $refused ) {
197            return $refused;
198        }
199
200        $result = self::run( $slug, $action );
201
202        if ( is_wp_error( $result ) && 'install' === $action ) {
203            $result = self::explain_install_error( $result );
204        }
205
206        if ( is_wp_error( $result ) ) {
207            $data = $result->get_error_data();
208
209            if ( ! is_array( $data ) || ! isset( $data['status'] ) ) {
210                $result->add_data( array( 'status' => 400 ), $result->get_error_code() );
211            }
212
213            return $result;
214        }
215
216        return rest_ensure_response( Main_Features::get_state( Initializer::is_offline_features_enabled() ) );
217    }
218
219    /**
220     * Switch several plugins and modules on or off in one request.
221     *
222     * Each is tried in turn and a failure does not stop the rest, so the response carries the
223     * fresh state together with what could not be switched and why.
224     *
225     * @param WP_REST_Request $request The request.
226     * @return \WP_REST_Response The Features tab's fresh state, and a list of failures.
227     */
228    public static function switch_many( $request ) {
229        $active = (bool) $request->get_param( 'active' );
230        $action = $active ? 'activate' : 'deactivate';
231        $failed = array();
232
233        // Jetpack carries My Jetpack and is never switched off here, so while it is active no
234        // batch can take this page with it. Without it, any plugin in the batch might be the last
235        // carrier, and checking them one by one lets a batch switch off every carrier in turn.
236        $plugins_refused = ! $active && Main_Features::PLUGIN_ACTIVE !== Main_Features::get_plugin_status( Product::JETPACK_PLUGIN_SLUG )
237            ? 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' ) )
238            : null;
239        // Multisite site administrators, and administrators whose host withholds plugin changes, may switch modules but not plugins.
240        if ( ! self::permissions_callback() ) {
241            $plugins_refused = new WP_Error( 'not_allowed', __( 'Your account can’t activate or deactivate plugins on this site.', 'jetpack-my-jetpack' ) );
242        }
243
244        foreach ( array_unique( (array) $request->get_param( 'plugins' ) ) as $slug ) {
245            $result = $plugins_refused ? $plugins_refused : self::refuse_deactivation( $slug, $action );
246            $result = $result ? $result : self::run( $slug, $action );
247
248            if ( is_wp_error( $result ) ) {
249                $failed[] = array(
250                    'type'    => 'plugin',
251                    'slug'    => $slug,
252                    'message' => $result->get_error_message(),
253                );
254            }
255        }
256
257        $modules_refused = current_user_can( 'jetpack_manage_modules' ) && ( self::permissions_callback() || self::is_jetpack_active() )
258            ? null
259            : new WP_Error( 'not_allowed', __( 'You are not allowed to manage Jetpack modules on this site.', 'jetpack-my-jetpack' ) );
260
261        foreach ( array_unique( (array) $request->get_param( 'modules' ) ) as $slug ) {
262            $result = $modules_refused ? $modules_refused : self::switch_module( $slug, $active );
263
264            if ( is_wp_error( $result ) ) {
265                $failed[] = array(
266                    'type'    => 'module',
267                    'slug'    => $slug,
268                    'message' => $result->get_error_message(),
269                );
270            }
271        }
272
273        return rest_ensure_response(
274            array(
275                'state'  => Main_Features::get_state( Initializer::is_offline_features_enabled() ),
276                'failed' => $failed,
277            )
278        );
279    }
280
281    /**
282     * Switch one Jetpack module, with the checks Jetpack's own module route makes.
283     *
284     * @param string $slug   Module slug.
285     * @param bool   $active Whether to switch it on.
286     * @return true|WP_Error
287     */
288    private static function switch_module( $slug, $active ) {
289        $modules = new Modules();
290
291        // Not is_module(): with no modules on offer, its allow-list is skipped and any slug passes.
292        if ( ! in_array( $slug, (array) $modules->get_available(), true ) ) {
293            return new WP_Error( 'not_found', __( 'That Jetpack module was not found.', 'jetpack-my-jetpack' ) );
294        }
295
296        // Already where it was asked to be: nothing to do, as with plugins above.
297        if ( $modules->is_active( $slug ) === $active ) {
298            return true;
299        }
300
301        if ( $active && Initializer::is_offline_features_enabled()
302            && ! Main_Features::module_works_locally( $slug )
303        ) {
304            return new WP_Error( 'switch_failed', __( 'Could not be switched on: unavailable in Offline Mode.', 'jetpack-my-jetpack' ) );
305        }
306
307        // Gotcha: activate() still redirects and exits when a legacy plugin it replaces (such as
308        // stats/stats.php) is active, which ends this request, as it does Jetpack's own route.
309        $switched = $active ? $modules->activate( $slug, false, false ) : $modules->deactivate( $slug );
310
311        // Saved, but a jetpack_active_modules callback can still hold it where it was.
312        if ( ( $switched || ! $active ) && $modules->is_active( $slug ) !== $active ) {
313            return $active
314                ? new WP_Error( 'module_forced', __( 'Stays off: disabled by your host or site administrator.', 'jetpack-my-jetpack' ) )
315                : new WP_Error( 'module_forced', __( 'Stays on: enabled by your host or site administrator.', 'jetpack-my-jetpack' ) );
316        }
317
318        // Read after a feature name, alone or in a list; a retry rarely helps, so none is offered.
319        if ( ! $switched ) {
320            return $active
321                ? 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' ) )
322                : new WP_Error( 'switch_failed', __( 'Could not be switched off.', 'jetpack-my-jetpack' ) );
323        }
324
325        return true;
326    }
327
328    /**
329     * Refuse an install the current user can't make, saying whether the site or their role is why.
330     *
331     * @return WP_Error|null The refusal, or null when the install may go ahead.
332     */
333    private static function refuse_install() {
334        switch ( Main_Features::get_install_access() ) {
335            case Main_Features::INSTALLS_DISABLED:
336                return new WP_Error(
337                    'install_disabled',
338                    __( 'Plugin installs are turned off on this site. Ask your host or site administrator to install it.', 'jetpack-my-jetpack' ),
339                    array( 'status' => 403 )
340                );
341
342            case Main_Features::INSTALLS_NOT_PERMITTED:
343                return new WP_Error(
344                    'not_allowed',
345                    __( 'Your account can’t install plugins on this site. Ask a site administrator to install it.', 'jetpack-my-jetpack' ),
346                    array( 'status' => 403 )
347                );
348
349            default:
350                return null;
351        }
352    }
353
354    /**
355     * Say what to do about an install that failed, where the installer's own message doesn't.
356     *
357     * @param WP_Error $error What the installer returned.
358     * @return WP_Error The same error, reworded when it is a download or filesystem failure.
359     */
360    private static function explain_install_error( WP_Error $error ) {
361        $code = (string) $error->get_error_code();
362
363        // Plugins_Installer reports a failed download as no_package.
364        if ( in_array( $code, array( 'no_package', 'download_failed' ), true ) ) {
365            $message = __( 'The plugin could not be downloaded from WordPress.org. Check that your site can reach WordPress.org, then try again.', 'jetpack-my-jetpack' );
366        } elseif ( preg_match( '/^(fs_|mkdir_failed|copy_failed)/', $code ) ) {
367            $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' );
368        } else {
369            return $error;
370        }
371
372        return new WP_Error( $code, $message, array( 'status' => 400 ) );
373    }
374
375    /**
376     * Refuse to switch off Jetpack, or the plugin serving this page when nothing else carries My Jetpack.
377     *
378     * @param string $slug   WordPress.org plugin slug.
379     * @param string $action One of install, activate or deactivate.
380     * @return WP_Error|null The refusal, or null when the action may go ahead.
381     */
382    private static function refuse_deactivation( $slug, $action ) {
383        if ( 'deactivate' !== $action ) {
384            return null;
385        }
386
387        // The autoloader picks one of the active carriers, so the one serving this request is
388        // refused only when nothing else could take over on the next load.
389        if ( Product::JETPACK_PLUGIN_SLUG === $slug
390            || ( Main_Features::is_hosting_plugin( $slug )
391                && Main_Features::is_only_my_jetpack_provider( Main_Features::get_hosting_plugin_slug() ) ) ) {
392            return new WP_Error(
393                'not_allowed',
394                __( 'This plugin runs the page you are on, so it cannot be deactivated from here.', 'jetpack-my-jetpack' ),
395                array( 'status' => 400 )
396            );
397        }
398
399        return null;
400    }
401
402    /**
403     * Carry out the action on the plugin and its product.
404     *
405     * @param string $slug   WordPress.org plugin slug.
406     * @param string $action One of install, activate or deactivate.
407     * @return true|WP_Error
408     */
409    private static function run( $slug, $action ) {
410        if ( ! function_exists( 'activate_plugin' ) ) {
411            require_once ABSPATH . 'wp-admin/includes/plugin.php';
412        }
413
414        $product_class = Main_Features::get_product_class_for_plugin( $slug );
415        $file          = Main_Features::get_plugin_file( $slug, $product_class );
416
417        if ( 'deactivate' === $action ) {
418            if ( ! $file ) {
419                return new WP_Error( 'not_installed', __( 'That plugin is not installed.', 'jetpack-my-jetpack' ) );
420            }
421
422            $is_hybrid   = $product_class && is_subclass_of( $product_class, Hybrid_Product::class );
423            $module_name = $is_hybrid ? $product_class::get_module_name() : null;
424            deactivate_plugins( $file );
425            if ( $is_hybrid ) {
426                return $product_class::do_product_specific_deactivation( $module_name );
427            }
428
429            return true;
430        }
431
432        // Already on: nothing to do, and re-running the product's activation step would
433        // reset what it set the first time — Boost's jb_get_started, Search's Instant
434        // Search. Reachable whenever a request is retried.
435        if ( $file && Main_Features::PLUGIN_ACTIVE === Main_Features::get_plugin_status( $slug, $product_class ) ) {
436            return true;
437        }
438
439        // Installing what is already here would put a second copy beside it, which is what
440        // a plugin in a -dev folder looks like to a lookup by slug.
441        if ( 'install' === $action && ! $file ) {
442            $installed = Plugins_Installer::install_and_activate_plugin( $slug );
443
444            if ( is_wp_error( $installed ) ) {
445                return $installed;
446            }
447        } else {
448            if ( ! $file ) {
449                return new WP_Error( 'not_installed', __( 'That plugin is not installed.', 'jetpack-my-jetpack' ) );
450            }
451
452            $activated = activate_plugin( $file );
453
454            if ( is_wp_error( $activated ) ) {
455                return $activated;
456            }
457        }
458
459        // A plugin alone is not the whole product: Search still has to switch Instant Search
460        // on, Boost to mark itself started, and the Hybrid products to enable their module.
461        // A refusal is not fatal here — the plugin is on either way, so failing the request
462        // would contradict the state it returns and invite a retry of what already happened.
463        if ( $product_class ) {
464            if ( is_subclass_of( $product_class, Hybrid_Product::class ) ) {
465                $product_class::do_product_specific_activation( true, Initializer::is_offline_features_enabled() );
466            } else {
467                $product_class::do_product_specific_activation( true );
468            }
469        }
470
471        return true;
472    }
473}