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