PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 15.8.1
Jetpack – WP Security, Backup, Speed, & Growth v15.8.1
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 13.8.3 13.9.2 14.0.1 14.1.1 14.2.2 14.3.1 14.4.2 14.5.1 14.6.1 14.7.1 14.8.1 14.9.2 15.0.3 15.1.2 15.2.1 15.3.2 15.4.1 15.5.1 15.6.1 15.7.2 15.8.1 15.9.2 16.0.2 16.1.3 16.2-a.5 16.2-a.3 16.1.2 16.2-a.1 16.1.1 16.1 16.1-beta 16.1-beta.2 16.1-beta.3 16.1-a.5 16.1-a.3 16.0.1 16.1-a.1 16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / src / abilities / class-modules-abilities.php
jetpack / src / abilities Last commit date
class-modules-abilities.php 3 months ago
class-modules-abilities.php
445 lines
1 <?php
2 /**
3 * Jetpack Modules Abilities Registration
4 *
5 * Registers Jetpack module management abilities with the WordPress Abilities API.
6 *
7 * @package automattic/jetpack
8 */
9
10 // @phan-file-suppress PhanUndeclaredFunction, PhanUndeclaredClassMethod @phan-suppress-current-line UnusedSuppression -- Abilities API added in WP 6.9; suppressions needed for older-WP compatibility runs.
11
12 namespace Automattic\Jetpack\Plugin\Abilities;
13
14 use Automattic\Jetpack\WP_Abilities\Registrar;
15 use Jetpack;
16
17 /**
18 * Registers Jetpack module management abilities with the WordPress Abilities API.
19 *
20 * Exposes a filtered read (`get-modules`) and a declarative state-setter
21 * (`set-module-status`) so AI agents can discover and toggle Jetpack modules
22 * through the standard `wp-abilities/v1` REST surface.
23 */
24 class Modules_Abilities extends Registrar {
25
26 /**
27 * {@inheritDoc}
28 */
29 public static function get_category_slug(): string {
30 return 'jetpack';
31 }
32
33 /**
34 * {@inheritDoc}
35 */
36 public static function get_category_definition(): array {
37 return array(
38 // "Jetpack" is a product name and should not be translated.
39 'label' => 'Jetpack',
40 'description' => __( 'Abilities exposed by the Jetpack plugin.', 'jetpack' ),
41 );
42 }
43
44 /**
45 * {@inheritDoc}
46 */
47 public static function get_abilities(): array {
48 $module_schema = array(
49 'type' => 'object',
50 'properties' => array(
51 'slug' => array( 'type' => 'string' ),
52 'name' => array( 'type' => 'string' ),
53 'description' => array( 'type' => 'string' ),
54 'active' => array( 'type' => 'boolean' ),
55 'sort' => array( 'type' => 'integer' ),
56 'feature' => array(
57 'type' => 'array',
58 'items' => array( 'type' => 'string' ),
59 ),
60 'plan_classes' => array(
61 'type' => 'array',
62 'items' => array( 'type' => 'string' ),
63 ),
64 'requires_connection' => array( 'type' => 'boolean' ),
65 'requires_user_connection' => array( 'type' => 'boolean' ),
66 'auto_activate' => array( 'type' => 'string' ),
67 ),
68 );
69
70 return array(
71 'jetpack/get-modules' => array(
72 'label' => __( 'Get Jetpack modules', 'jetpack' ),
73 'description' => __( 'Return zero or more Jetpack modules as an array. Each element has { slug, name, description, active, sort, feature, plan_classes, requires_connection, requires_user_connection, auto_activate }. Combine slug / active / feature / search filters to narrow the list. When slug is provided and unknown, the result is an empty array (not an error). Use this before calling jetpack/set-module-status to enumerate legal slugs.', 'jetpack' ),
74 'input_schema' => array(
75 'type' => 'object',
76 'default' => array(),
77 'properties' => array(
78 'slug' => array(
79 'type' => 'string',
80 'description' => __( 'Return a single module by slug. Unknown slugs yield an empty array.', 'jetpack' ),
81 'minLength' => 1,
82 ),
83 'active' => array(
84 'type' => 'boolean',
85 'description' => __( 'When set, only return modules whose current active state matches this value.', 'jetpack' ),
86 ),
87 'feature' => array(
88 'type' => 'string',
89 'description' => __( 'Case-insensitive match against a module\'s feature tag (e.g. "Recommended", "Security", "Performance").', 'jetpack' ),
90 'minLength' => 1,
91 ),
92 'search' => array(
93 'type' => 'string',
94 'description' => __( 'Case-insensitive substring match against the module name, slug, and description.', 'jetpack' ),
95 'minLength' => 1,
96 ),
97 ),
98 'additionalProperties' => false,
99 ),
100 'output_schema' => array(
101 'type' => 'array',
102 'items' => $module_schema,
103 ),
104 'execute_callback' => array( __CLASS__, 'get_modules' ),
105 'permission_callback' => array( __CLASS__, 'can_view_modules' ),
106 'meta' => array(
107 'annotations' => array(
108 'readonly' => true,
109 'destructive' => false,
110 'idempotent' => true,
111 ),
112 'show_in_rest' => true,
113 'mcp' => array(
114 'public' => true,
115 'type' => 'tool', // default is already "tool", but can be explicit.
116 ),
117 ),
118 ),
119
120 'jetpack/set-module-status' => array(
121 'label' => __( 'Set Jetpack module status', 'jetpack' ),
122 'description' => __( 'Set a Jetpack module\'s active state. Idempotent — setting a module to its current state returns changed=false. Returns { slug, active, changed }. Call jetpack/get-modules first to enumerate valid slugs. Modules requiring a Jetpack connection or a paid plan may fail with jetpack_modules_activate_failed; the message indicates the next step.', 'jetpack' ),
123 'input_schema' => array(
124 'type' => 'object',
125 'required' => array( 'slug', 'active' ),
126 'properties' => array(
127 'slug' => array(
128 'type' => 'string',
129 'description' => __( 'The Jetpack module slug (e.g. "stats", "sso", "sharedaddy").', 'jetpack' ),
130 'minLength' => 1,
131 ),
132 'active' => array(
133 'type' => 'boolean',
134 'description' => __( 'Desired active state. true activates the module; false deactivates it.', 'jetpack' ),
135 ),
136 ),
137 'additionalProperties' => false,
138 ),
139 'output_schema' => array(
140 'type' => 'object',
141 'properties' => array(
142 'slug' => array( 'type' => 'string' ),
143 'active' => array( 'type' => 'boolean' ),
144 'changed' => array( 'type' => 'boolean' ),
145 ),
146 ),
147 'execute_callback' => array( __CLASS__, 'set_module_status' ),
148 'permission_callback' => array( __CLASS__, 'can_manage_modules' ),
149 'meta' => array(
150 'annotations' => array(
151 'readonly' => false,
152 'destructive' => false,
153 'idempotent' => true,
154 ),
155 'show_in_rest' => true,
156 'mcp' => array(
157 'public' => true,
158 'type' => 'tool', // default is already "tool", but can be explicit.
159 ),
160 ),
161 ),
162 );
163 }
164
165 /**
166 * Permission check: can the current user read module listings?
167 *
168 * Mirrors the capability used by the Jetpack admin page so subscribers and
169 * contributors are denied.
170 */
171 public static function can_view_modules(): bool {
172 return current_user_can( 'jetpack_admin_page' );
173 }
174
175 /**
176 * Permission check: can the current user toggle modules?
177 */
178 public static function can_manage_modules(): bool {
179 return current_user_can( 'jetpack_manage_modules' )
180 && current_user_can( 'jetpack_activate_modules' );
181 }
182
183 /**
184 * Build the high-signal summary shape used by `get-modules`.
185 *
186 * Adapts the raw `Jetpack::get_module()` array down to the fields agents
187 * actually consume — dropping internal bookkeeping like `module_tags`,
188 * changelog URLs, and file paths. Applies the site's current locale to
189 * `name` and `description` so agent-facing output mirrors what a user
190 * would see in the admin UI.
191 *
192 * @param string $slug Module slug.
193 * @param bool $is_active Whether the module is currently active. Passed in to avoid
194 * O(N) calls to the `jetpack_active_modules` filter in a list loop.
195 * @return array|null Compact module entry, or null when the module has no info.
196 */
197 private static function summarize_module( $slug, $is_active ) {
198 $mod = Jetpack::get_module( $slug );
199 if ( ! is_array( $mod ) ) {
200 return null;
201 }
202
203 $i18n = function_exists( 'jetpack_get_module_i18n' ) ? jetpack_get_module_i18n( $slug ) : array();
204 $name = isset( $i18n['name'] ) ? (string) $i18n['name'] : ( isset( $mod['name'] ) ? (string) $mod['name'] : $slug );
205 $desc = isset( $i18n['description'] ) ? (string) $i18n['description'] : ( isset( $mod['description'] ) ? (string) $mod['description'] : '' );
206
207 return array(
208 'slug' => $slug,
209 'name' => $name,
210 'description' => $desc,
211 'active' => $is_active,
212 'sort' => isset( $mod['sort'] ) ? (int) $mod['sort'] : 10,
213 'feature' => isset( $mod['feature'] ) && is_array( $mod['feature'] ) ? array_values( $mod['feature'] ) : array(),
214 'plan_classes' => isset( $mod['plan_classes'] ) && is_array( $mod['plan_classes'] ) ? array_values( $mod['plan_classes'] ) : array(),
215 'requires_connection' => ! empty( $mod['requires_connection'] ),
216 'requires_user_connection' => ! empty( $mod['requires_user_connection'] ),
217 'auto_activate' => isset( $mod['auto_activate'] ) ? (string) $mod['auto_activate'] : 'No',
218 );
219 }
220
221 /**
222 * Consolidated read callback. Returns an array of module summaries.
223 *
224 * When `slug` is provided, returns a 0- or 1-element array; unknown slugs
225 * yield an empty array rather than a `WP_Error` so the shape is uniform.
226 *
227 * @param array|null $input Input matching the ability's input_schema.
228 * @return array
229 */
230 public static function get_modules( $input = null ) {
231 $input = is_array( $input ) ? $input : array();
232
233 // Fetch the active-slug map once per call — `Jetpack::is_module_active()` fires the
234 // user-extensible `jetpack_active_modules` filter on each call, so looking up
235 // active state per-module in a loop amplifies any hook cost by N.
236 $active_map = array_flip( Jetpack::get_active_modules() );
237
238 // Narrow the candidate set up front when `slug` is supplied; remaining
239 // filters (active / feature / search) still apply so combinations like
240 // { slug: 'stats', active: false } correctly return an empty array when
241 // stats is active.
242 if ( isset( $input['slug'] ) && is_string( $input['slug'] ) && '' !== $input['slug'] ) {
243 if ( ! Jetpack::is_module( $input['slug'] ) ) {
244 return array();
245 }
246 $candidate_slugs = array( $input['slug'] );
247 } else {
248 $candidate_slugs = Jetpack::get_available_modules();
249 }
250
251 $active_filter = array_key_exists( 'active', $input ) && is_bool( $input['active'] ) ? $input['active'] : null;
252 $feature_filter = isset( $input['feature'] ) && is_string( $input['feature'] ) && '' !== $input['feature']
253 ? strtolower( $input['feature'] )
254 : null;
255 $search_filter = isset( $input['search'] ) && is_string( $input['search'] ) && '' !== $input['search']
256 ? strtolower( $input['search'] )
257 : null;
258
259 $out = array();
260 foreach ( $candidate_slugs as $slug ) {
261 $summary = self::summarize_module( $slug, isset( $active_map[ $slug ] ) );
262 if ( null === $summary ) {
263 continue;
264 }
265
266 if ( null !== $active_filter && $summary['active'] !== $active_filter ) {
267 continue;
268 }
269
270 if ( null !== $feature_filter ) {
271 $features_lower = array_map( 'strtolower', $summary['feature'] );
272 if ( ! in_array( $feature_filter, $features_lower, true ) ) {
273 continue;
274 }
275 }
276
277 if ( null !== $search_filter ) {
278 $haystack = strtolower( $summary['slug'] . ' ' . $summary['name'] . ' ' . $summary['description'] );
279 if ( false === strpos( $haystack, $search_filter ) ) {
280 continue;
281 }
282 }
283
284 $out[] = $summary;
285 }
286
287 usort(
288 $out,
289 static function ( $a, $b ) {
290 if ( $a['sort'] === $b['sort'] ) {
291 return strcmp( $a['slug'], $b['slug'] );
292 }
293 return $a['sort'] <=> $b['sort'];
294 }
295 );
296
297 return $out;
298 }
299
300 /**
301 * Declarative state-setter callback. Idempotent: returns changed=false
302 * when the desired state already matches current state.
303 *
304 * @param array|null $input Input matching the ability's input_schema.
305 * @return array|\WP_Error
306 */
307 public static function set_module_status( $input = null ) {
308 $input = is_array( $input ) ? $input : array();
309
310 if ( ! isset( $input['slug'] ) || ! is_string( $input['slug'] ) || '' === $input['slug'] ) {
311 return new \WP_Error(
312 'jetpack_modules_missing_slug',
313 __( 'A module slug is required. Call jetpack/get-modules to enumerate valid slugs.', 'jetpack' )
314 );
315 }
316
317 if ( ! array_key_exists( 'active', $input ) ) {
318 return new \WP_Error(
319 'jetpack_modules_missing_active',
320 __( 'A desired active state (boolean) is required.', 'jetpack' )
321 );
322 }
323 if ( ! is_bool( $input['active'] ) ) {
324 return new \WP_Error(
325 'jetpack_modules_invalid_active',
326 __( 'The active parameter must be a boolean. Strings like "true" / "false" are not accepted.', 'jetpack' )
327 );
328 }
329
330 $slug = $input['slug'];
331 $desired = $input['active'];
332
333 if ( ! Jetpack::is_module( $slug ) ) {
334 return new \WP_Error(
335 'jetpack_modules_invalid_slug',
336 __( 'Unknown Jetpack module slug. Call jetpack/get-modules to enumerate valid slugs.', 'jetpack' )
337 );
338 }
339
340 $current = Jetpack::is_module_active( $slug );
341
342 if ( $desired === $current ) {
343 return array(
344 'slug' => $slug,
345 'active' => $current,
346 'changed' => false,
347 );
348 }
349
350 if ( $desired ) {
351 // Preflight: Jetpack::activate_module() ignores its $exit/$redirect flags when a
352 // conflicting standalone plugin (e.g. WordPress.com Stats vs. the stats module) is
353 // active — it calls wp_safe_redirect()+exit() and would terminate this REST request
354 // mid-response. Refuse here with a structured error instead.
355 $conflict = self::find_conflicting_active_plugin( $slug );
356 if ( null !== $conflict ) {
357 return new \WP_Error(
358 'jetpack_modules_conflicting_plugin_active',
359 sprintf(
360 /* translators: %s: name of the conflicting plugin that must be deactivated first. */
361 __( 'Cannot activate the module while a conflicting plugin (%s) is active. Deactivate it on the WordPress Plugins screen, then retry.', 'jetpack' ),
362 $conflict
363 )
364 );
365 }
366
367 // Always pass exit=false, redirect=false so the ability runs headless over REST.
368 $ok = Jetpack::activate_module( $slug, false, false );
369 if ( ! $ok ) {
370 return new \WP_Error(
371 'jetpack_modules_activate_failed',
372 __( 'Unable to activate the module. It may require a Jetpack connection or a higher plan. Inspect requires_connection and plan_classes on the module and retry after those preconditions are met.', 'jetpack' )
373 );
374 }
375 } else {
376 $ok = Jetpack::deactivate_module( $slug );
377 if ( ! $ok ) {
378 return new \WP_Error(
379 'jetpack_modules_deactivate_failed',
380 __( 'Unable to deactivate the module.', 'jetpack' )
381 );
382 }
383 }
384
385 // Re-check state: activate_module() / deactivate_module() can return truthy even when a
386 // pre_update_option_jetpack_active_modules filter blocks the option write, so the response
387 // would otherwise lie about reaching the requested state.
388 $actual = Jetpack::is_module_active( $slug );
389 if ( $desired !== $actual ) {
390 return new \WP_Error(
391 'jetpack_modules_state_mismatch',
392 __( 'The module did not reach the requested state. A filter on jetpack_active_modules may have rejected the change.', 'jetpack' )
393 );
394 }
395
396 return array(
397 'slug' => $slug,
398 'active' => $actual,
399 'changed' => true,
400 );
401 }
402
403 /**
404 * Return the human-readable name of the first standalone plugin currently active that
405 * would force {@see Jetpack::activate_module()} to redirect/exit, or null when none.
406 *
407 * Mirrors the lookup in {@see Jetpack_Client_Server::deactivate_plugin()}: prefer the
408 * known plugin file path, fall back to a name match across active plugins.
409 *
410 * @param string $slug Module slug.
411 * @return string|null
412 */
413 private static function find_conflicting_active_plugin( $slug ) {
414 $jetpack = Jetpack::init();
415 if ( empty( $jetpack->plugins_to_deactivate[ $slug ] ) ) {
416 return null;
417 }
418
419 if ( ! function_exists( 'is_plugin_active' ) ) {
420 require_once ABSPATH . 'wp-admin/includes/plugin.php';
421 }
422
423 $active_plugins = null;
424 foreach ( $jetpack->plugins_to_deactivate[ $slug ] as $candidate ) {
425 list( $plugin_file, $plugin_name ) = $candidate;
426
427 if ( is_plugin_active( $plugin_file ) ) {
428 return $plugin_name;
429 }
430
431 if ( null === $active_plugins ) {
432 $active_plugins = Jetpack::get_active_plugins();
433 }
434 foreach ( $active_plugins as $active ) {
435 $data = get_plugin_data( WP_PLUGIN_DIR . '/' . $active );
436 if ( isset( $data['Name'] ) && $data['Name'] === $plugin_name ) {
437 return $plugin_name;
438 }
439 }
440 }
441
442 return null;
443 }
444 }
445