Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.00% covered (success)
91.00%
192 / 211
50.00% covered (danger)
50.00%
6 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
REST_Main_Features
91.00% covered (success)
91.00%
192 / 211
50.00% covered (danger)
50.00%
6 / 12
79.11
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
87.80% covered (warning)
87.80%
36 / 41
0.00% covered (danger)
0.00%
0 / 1
17.52
 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__ . '::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        // Without Jetpack, any plugin in the batch may be the last copy of My Jetpack.
214        $plugins_refused = ! $active && Main_Features::PLUGIN_ACTIVE !== Main_Features::get_plugin_status( Product::JETPACK_PLUGIN_SLUG )
215            ? 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' ) )
216            : null;
217
218        $plugins          = array_unique( (array) $request->get_param( 'plugins' ) );
219        $excluded_folders = array();
220        // Jetpack stays active, but older copies cannot serve offline Features.
221        // Offline, the hosting-plugin check must exclude every plugin the batch would deactivate.
222        if ( ! $active && Initializer::is_offline_features_enabled() ) {
223            foreach ( $plugins as $slug ) {
224                if ( Product::JETPACK_PLUGIN_SLUG === $slug ) {
225                    continue;
226                }
227                $file = Main_Features::get_plugin_file( $slug, Main_Features::get_product_class_for_plugin( $slug ) );
228                if ( $file ) {
229                    $excluded_folders[] = dirname( $file );
230                }
231            }
232        }
233
234        foreach ( $plugins as $slug ) {
235            $result = $plugins_refused ? $plugins_refused : self::refuse_deactivation( $slug, $action, $excluded_folders );
236            $result = $result ? $result : self::run( $slug, $action );
237
238            if ( is_wp_error( $result ) ) {
239                $failed[] = array(
240                    'type'    => 'plugin',
241                    'slug'    => $slug,
242                    'message' => $result->get_error_message(),
243                );
244            }
245        }
246
247        $modules_refused = current_user_can( 'jetpack_manage_modules' )
248            ? null
249            : new WP_Error( 'not_allowed', __( 'You are not allowed to manage Jetpack modules on this site.', 'jetpack-my-jetpack' ) );
250
251        foreach ( array_unique( (array) $request->get_param( 'modules' ) ) as $slug ) {
252            $result = $modules_refused ? $modules_refused : self::switch_module( $slug, $active );
253
254            if ( is_wp_error( $result ) ) {
255                $failed[] = array(
256                    'type'    => 'module',
257                    'slug'    => $slug,
258                    'message' => $result->get_error_message(),
259                );
260            }
261        }
262
263        return rest_ensure_response(
264            array(
265                'state'  => Main_Features::get_state( Initializer::is_offline_features_enabled() ),
266                'failed' => $failed,
267            )
268        );
269    }
270
271    /**
272     * Switch one Jetpack module, with the checks Jetpack's own module route makes.
273     *
274     * @param string $slug   Module slug.
275     * @param bool   $active Whether to switch it on.
276     * @return true|WP_Error
277     */
278    private static function switch_module( $slug, $active ) {
279        $modules = new Modules();
280
281        // Not is_module(): with no modules on offer, its allow-list is skipped and any slug passes.
282        if ( ! in_array( $slug, (array) $modules->get_available(), true ) ) {
283            return new WP_Error( 'not_found', __( 'That Jetpack module was not found.', 'jetpack-my-jetpack' ) );
284        }
285
286        // Already where it was asked to be: nothing to do, as with plugins above.
287        if ( $modules->is_active( $slug ) === $active ) {
288            return true;
289        }
290
291        if ( $active && Initializer::is_offline_features_enabled()
292            && ! Main_Features::module_works_locally( $slug )
293        ) {
294            return new WP_Error( 'switch_failed', __( 'Could not be switched on: unavailable in Offline Mode.', 'jetpack-my-jetpack' ) );
295        }
296
297        // Gotcha: activate() still redirects and exits when a legacy plugin it replaces (such as
298        // stats/stats.php) is active, which ends this request, as it does Jetpack's own route.
299        $switched = $active ? $modules->activate( $slug, false, false ) : $modules->deactivate( $slug );
300
301        // Saved, but a jetpack_active_modules callback can still hold it where it was.
302        if ( ( $switched || ! $active ) && $modules->is_active( $slug ) !== $active ) {
303            return $active
304                ? new WP_Error( 'module_forced', __( 'Stays off: disabled by your host or site administrator.', 'jetpack-my-jetpack' ) )
305                : new WP_Error( 'module_forced', __( 'Stays on: enabled by your host or site administrator.', 'jetpack-my-jetpack' ) );
306        }
307
308        // Read after a feature name, alone or in a list; a retry rarely helps, so none is offered.
309        if ( ! $switched ) {
310            return $active
311                ? 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' ) )
312                : new WP_Error( 'switch_failed', __( 'Could not be switched off.', 'jetpack-my-jetpack' ) );
313        }
314
315        return true;
316    }
317
318    /**
319     * Refuse an install the current user can't make, saying whether the site or their role is why.
320     *
321     * @return WP_Error|null The refusal, or null when the install may go ahead.
322     */
323    private static function refuse_install() {
324        switch ( Main_Features::get_install_access() ) {
325            case Main_Features::INSTALLS_DISABLED:
326                return new WP_Error(
327                    'install_disabled',
328                    __( 'Plugin installs are turned off on this site. Ask your host or site administrator to install it.', 'jetpack-my-jetpack' ),
329                    array( 'status' => 403 )
330                );
331
332            case Main_Features::INSTALLS_NOT_PERMITTED:
333                return new WP_Error(
334                    'not_allowed',
335                    __( 'Your account can’t install plugins on this site. Ask a site administrator to install it.', 'jetpack-my-jetpack' ),
336                    array( 'status' => 403 )
337                );
338
339            default:
340                return null;
341        }
342    }
343
344    /**
345     * Say what to do about an install that failed, where the installer's own message doesn't.
346     *
347     * @param WP_Error $error What the installer returned.
348     * @return WP_Error The same error, reworded when it is a download or filesystem failure.
349     */
350    private static function explain_install_error( WP_Error $error ) {
351        $code = (string) $error->get_error_code();
352
353        // Plugins_Installer reports a failed download as no_package.
354        if ( in_array( $code, array( 'no_package', 'download_failed' ), true ) ) {
355            $message = __( 'The plugin could not be downloaded from WordPress.org. Check that your site can reach WordPress.org, then try again.', 'jetpack-my-jetpack' );
356        } elseif ( preg_match( '/^(fs_|mkdir_failed|copy_failed)/', $code ) ) {
357            $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' );
358        } else {
359            return $error;
360        }
361
362        return new WP_Error( $code, $message, array( 'status' => 400 ) );
363    }
364
365    /**
366     * Keep Jetpack active, and keep the serving plugin when no remaining copy can serve this page.
367     *
368     * @since $$next-version$$ Added the $excluded_folders parameter.
369     * @param string   $slug             WordPress.org plugin slug.
370     * @param string   $action           One of install, activate or deactivate.
371     * @param string[] $excluded_folders Other plugin folders being deactivated in the same request.
372     * @return WP_Error|null The refusal, or null when the action may go ahead.
373     */
374    private static function refuse_deactivation( $slug, $action, $excluded_folders = array() ) {
375        if ( 'deactivate' !== $action ) {
376            return null;
377        }
378
379        // The autoloader picks one of the active carriers, so the one serving this request is
380        // refused only when nothing else could take over on the next load.
381        if ( Product::JETPACK_PLUGIN_SLUG === $slug
382            || ( Main_Features::is_hosting_plugin( $slug )
383                && Main_Features::is_only_my_jetpack_provider( Main_Features::get_hosting_plugin_slug(), $excluded_folders ) ) ) {
384            return new WP_Error(
385                'not_allowed',
386                __( 'This plugin runs the page you are on, so it cannot be deactivated from here.', 'jetpack-my-jetpack' ),
387                array( 'status' => 400 )
388            );
389        }
390
391        return null;
392    }
393
394    /**
395     * Carry out the action on the plugin and its product.
396     *
397     * @param string $slug   WordPress.org plugin slug.
398     * @param string $action One of install, activate or deactivate.
399     * @return true|WP_Error
400     */
401    private static function run( $slug, $action ) {
402        if ( ! function_exists( 'activate_plugin' ) ) {
403            require_once ABSPATH . 'wp-admin/includes/plugin.php';
404        }
405
406        $product_class = Main_Features::get_product_class_for_plugin( $slug );
407        $file          = Main_Features::get_plugin_file( $slug, $product_class );
408
409        if ( 'deactivate' === $action ) {
410            if ( ! $file ) {
411                return new WP_Error( 'not_installed', __( 'That plugin is not installed.', 'jetpack-my-jetpack' ) );
412            }
413
414            $is_hybrid   = $product_class && is_subclass_of( $product_class, Hybrid_Product::class );
415            $module_name = $is_hybrid ? $product_class::get_module_name() : null;
416            deactivate_plugins( $file );
417            if ( $is_hybrid ) {
418                return $product_class::do_product_specific_deactivation( $module_name );
419            }
420
421            return true;
422        }
423
424        // Already on: nothing to do, and re-running the product's activation step would
425        // reset what it set the first time — Boost's jb_get_started, Search's Instant
426        // Search. Reachable whenever a request is retried.
427        if ( $file && Main_Features::PLUGIN_ACTIVE === Main_Features::get_plugin_status( $slug, $product_class ) ) {
428            return true;
429        }
430
431        // Installing what is already here would put a second copy beside it, which is what
432        // a plugin in a -dev folder looks like to a lookup by slug.
433        if ( 'install' === $action && ! $file ) {
434            $installed = Plugins_Installer::install_and_activate_plugin( $slug );
435
436            if ( is_wp_error( $installed ) ) {
437                return $installed;
438            }
439        } else {
440            if ( ! $file ) {
441                return new WP_Error( 'not_installed', __( 'That plugin is not installed.', 'jetpack-my-jetpack' ) );
442            }
443
444            $activated = activate_plugin( $file );
445
446            if ( is_wp_error( $activated ) ) {
447                return $activated;
448            }
449        }
450
451        // A plugin alone is not the whole product: Search still has to switch Instant Search
452        // on, Boost to mark itself started, and the Hybrid products to enable their module.
453        // A refusal is not fatal here — the plugin is on either way, so failing the request
454        // would contradict the state it returns and invite a retry of what already happened.
455        if ( $product_class ) {
456            if ( is_subclass_of( $product_class, Hybrid_Product::class ) ) {
457                $product_class::do_product_specific_activation( true, Initializer::is_offline_features_enabled() );
458            } else {
459                $product_class::do_product_specific_activation( true );
460            }
461        }
462
463        return true;
464    }
465}