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