PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 All 507 releases
jetpack / jetpack_vendor / automattic / jetpack-my-jetpack / src / class-rest-main-features.php

class-rest-main-features.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-beta, at jetpack_vendor/automattic/jetpack-my-jetpack/src/class-rest-main-features.php

438 lines 14.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * REST route that installs and switches the plugins behind the Features tab.
4 *
5 * @package automattic/my-jetpack
6 */
7
8 namespace Automattic\Jetpack\My_Jetpack;
9
10 use Automattic\Jetpack\Modules;
11 use Automattic\Jetpack\Plugins_Installer;
12 use WP_Error;
13 use WP_REST_Request;
14 use WP_REST_Server;
15
16 /**
17 * Installs, activates and deactivates the plugins and modules in the feature map.
18 */
19 class 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 }
438