PluginProbe
MainWP Dashboard: Self-hosted WordPress Management for Agencies / 6.2.2
MainWP Dashboard: Self-hosted WordPress Management for Agencies v6.2.2
6.2.2 6.2.1 6.2 6.1.8 6.1.7 6.1.6 6.1.5 6.1.4 6.1.3 6.1.2 6.1.1 6.1 6.0.12 6.0.11 4.6.0.1 5.0 5.0.1 5.0.2 5.0.3 5.0.3.1 5.0.3.2 5.1 5.1.1 5.2 5.2.1 All 155 releases
mainwp / includes / abilities / class-mainwp-abilities-sites.php

class-mainwp-abilities-sites.php in MainWP Dashboard: Self-hosted WordPress Management for Agencies 6.2.2, at includes/abilities/class-mainwp-abilities-sites.php

4,682 lines 182.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MainWP Sites Abilities
4 *
5 * @package MainWP\Dashboard
6 */
7
8 namespace MainWP\Dashboard;
9
10 if ( ! defined( 'ABSPATH' ) ) {
11 exit;
12 }
13
14 /**
15 * Class MainWP_Abilities_Sites
16 *
17 * Registers and implements site-related abilities for the MainWP Dashboard.
18 *
19 * This class provides 5 abilities:
20 * - mainwp/list-sites-v1: List MainWP child sites with pagination and filtering
21 * - mainwp/get-site-v1: Get detailed information about a single site
22 * - mainwp/sync-sites-v1: Trigger synchronization for one or more sites
23 * - mainwp/get-site-plugins-v1: Get list of plugins installed on a site
24 * - mainwp/get-site-themes-v1: Get list of themes installed on a site
25 *
26 * ## Input Handling for GET Requests
27 *
28 * Read-only abilities (readonly: true) use GET requests. WordPress REST API does NOT
29 * auto-parse JSON from query strings, so:
30 *
31 * - Omit `?input` parameter entirely to use schema defaults (recommended)
32 * - Use `?input=` (empty) which also triggers defaults
33 * - DO NOT use `?input=%7B%7D` (URL-encoded JSON) - it arrives as a string and fails validation
34 *
35 * Our input schemas use `'type' => array('object', 'null')` with defaults, so callers
36 * can simply call the endpoint without any input parameter.
37 *
38 * @see .mwpdev/docs/abilities-api-docs/known-issues.md for detailed explanation
39 */
40 class MainWP_Abilities_Sites { //phpcs:ignore -- NOSONAR - multi methods.
41
42 /**
43 * Register all site abilities.
44 *
45 * @return void
46 */
47 public static function register(): void {
48 if ( ! function_exists( 'wp_register_ability' ) ) {
49 return;
50 }
51
52 self::register_list_sites();
53 self::register_get_site();
54 self::register_sync_sites();
55 self::register_get_site_plugins();
56 self::register_get_site_themes();
57
58 // Connection Management (8).
59 self::register_add_site();
60 self::register_update_site();
61 self::register_delete_site();
62 self::register_reconnect_site();
63 self::register_disconnect_site();
64 self::register_suspend_site();
65 self::register_unsuspend_site();
66 self::register_check_site();
67
68 // Plugin Management (4).
69 self::register_activate_site_plugins();
70 self::register_deactivate_site_plugins();
71 self::register_delete_site_plugins();
72 self::register_get_abandoned_plugins();
73
74 // Theme Management (3).
75 self::register_activate_site_theme();
76 self::register_delete_site_themes();
77 self::register_get_abandoned_themes();
78
79 // Security & Monitoring (2).
80 self::register_get_site_security();
81 self::register_get_site_changes();
82
83 // Related Data (4).
84 self::register_get_site_client();
85 self::register_get_site_costs();
86 self::register_count_sites();
87 self::register_get_sites_basic();
88
89 // Batch Operations (4).
90 self::register_reconnect_sites();
91 self::register_disconnect_sites();
92 self::register_check_sites();
93 self::register_suspend_sites();
94 }
95
96 /**
97 * Register mainwp/list-sites-v1 ability.
98 *
99 * @return void
100 */
101 private static function register_list_sites(): void {
102 wp_register_ability(
103 'mainwp/list-sites-v1',
104 array(
105 'label' => __( 'List MainWP Sites', 'mainwp' ),
106 'description' => __( 'List MainWP child sites with pagination and filtering. Returns basic site information including ID, URL, name, and connection status.', 'mainwp' ),
107 'category' => 'mainwp-sites',
108 'input_schema' => self::get_list_sites_input_schema(),
109 'output_schema' => self::get_list_sites_output_schema(),
110 'execute_callback' => array( self::class, 'execute_list_sites' ),
111 'permission_callback' => array( MainWP_Abilities_Util::class, 'check_rest_api_permission' ),
112 'meta' => array(
113 'show_in_rest' => true,
114 'annotations' => array(
115 'instructions' => '',
116 'readonly' => true,
117 'destructive' => false,
118 'idempotent' => true,
119 ),
120 ),
121 )
122 );
123 }
124
125 /**
126 * Register mainwp/get-site-v1 ability.
127 *
128 * @return void
129 */
130 private static function register_get_site(): void {
131 wp_register_ability(
132 'mainwp/get-site-v1',
133 array(
134 'label' => __( 'Get MainWP Site', 'mainwp' ),
135 'description' => __( 'Get detailed information about a single MainWP child site.', 'mainwp' ),
136 'category' => 'mainwp-sites',
137 'input_schema' => self::get_get_site_input_schema(),
138 'output_schema' => self::get_site_output_schema(),
139 'execute_callback' => array( self::class, 'execute_get_site' ),
140 'permission_callback' => MainWP_Abilities_Util::make_site_permission_callback( 'site_id_or_domain' ),
141 'meta' => array(
142 'show_in_rest' => true,
143 'annotations' => array(
144 'instructions' => '',
145 'readonly' => true,
146 'destructive' => false,
147 'idempotent' => true,
148 ),
149 ),
150 )
151 );
152 }
153
154 /**
155 * Register mainwp/sync-sites-v1 ability.
156 *
157 * @return void
158 */
159 private static function register_sync_sites(): void {
160 wp_register_ability(
161 'mainwp/sync-sites-v1',
162 array(
163 'label' => __( 'Sync MainWP Sites', 'mainwp' ),
164 'description' => __( 'Trigger synchronization for one or more child sites. Operations with >200 sites are automatically queued for background processing.', 'mainwp' ),
165 'category' => 'mainwp-sites',
166 'input_schema' => self::get_sync_sites_input_schema(),
167 'output_schema' => self::get_sync_output_schema(),
168 'execute_callback' => array( self::class, 'execute_sync_sites' ),
169 'permission_callback' => array( MainWP_Abilities_Util::class, 'check_manage_sites_permission' ),
170 'meta' => array(
171 'show_in_rest' => true,
172 'annotations' => array(
173 'instructions' => 'Pass site_ids_or_domains with specific IDs, or empty array for all applicable sites. Operations with >200 sites are automatically queued for background processing.',
174 'readonly' => false,
175 'destructive' => false,
176 'idempotent' => true,
177 ),
178 ),
179 )
180 );
181 }
182
183 /**
184 * Register mainwp/get-site-plugins-v1 ability.
185 *
186 * @return void
187 */
188 private static function register_get_site_plugins(): void {
189 wp_register_ability(
190 'mainwp/get-site-plugins-v1',
191 array(
192 'label' => __( 'Get Site Plugins', 'mainwp' ),
193 'description' => __( 'Get list of plugins installed on a child site.', 'mainwp' ),
194 'category' => 'mainwp-sites',
195 'input_schema' => self::get_site_plugins_input_schema(),
196 'output_schema' => self::get_plugins_output_schema(),
197 'execute_callback' => array( self::class, 'execute_get_site_plugins' ),
198 'permission_callback' => MainWP_Abilities_Util::make_site_permission_callback( 'site_id_or_domain' ),
199 'meta' => array(
200 'show_in_rest' => true,
201 'annotations' => array(
202 'instructions' => '',
203 'readonly' => true,
204 'destructive' => false,
205 'idempotent' => true,
206 ),
207 ),
208 )
209 );
210 }
211
212 /**
213 * Register mainwp/get-site-themes-v1 ability.
214 *
215 * @return void
216 */
217 private static function register_get_site_themes(): void {
218 wp_register_ability(
219 'mainwp/get-site-themes-v1',
220 array(
221 'label' => __( 'Get Site Themes', 'mainwp' ),
222 'description' => __( 'Get list of themes installed on a child site.', 'mainwp' ),
223 'category' => 'mainwp-sites',
224 'input_schema' => self::get_site_themes_input_schema(),
225 'output_schema' => self::get_themes_output_schema(),
226 'execute_callback' => array( self::class, 'execute_get_site_themes' ),
227 'permission_callback' => MainWP_Abilities_Util::make_site_permission_callback( 'site_id_or_domain' ),
228 'meta' => array(
229 'show_in_rest' => true,
230 'annotations' => array(
231 'instructions' => '',
232 'readonly' => true,
233 'destructive' => false,
234 'idempotent' => true,
235 ),
236 ),
237 )
238 );
239 }
240
241 // =========================================================================
242 // Input Schema Definitions
243 // =========================================================================
244
245 /**
246 * Get input schema for list-sites-v1.
247 *
248 * Note: Uses 'type' => array('object', 'null') to allow callers to omit the input
249 * parameter entirely on GET requests. All properties have defaults, so no input
250 * is required. See class docblock for GET request input handling details.
251 *
252 * @return array
253 */
254 public static function get_list_sites_input_schema(): array {
255 return array(
256 'type' => array( 'object', 'null' ),
257 'properties' => array(
258 'page' => array(
259 'type' => 'integer',
260 'description' => __( 'Page number (1-based).', 'mainwp' ),
261 'default' => 1,
262 'minimum' => 1,
263 ),
264 'per_page' => array(
265 'type' => 'integer',
266 'description' => __( 'Items per page.', 'mainwp' ),
267 'default' => 20,
268 'minimum' => 1,
269 'maximum' => 100,
270 ),
271 'status' => array(
272 'type' => 'string',
273 'description' => __( 'Filter by connection status.', 'mainwp' ),
274 'enum' => array( 'any', 'connected', 'disconnected', 'suspended', 'available_update' ),
275 'default' => 'any',
276 ),
277 'search' => array(
278 'type' => 'string',
279 'description' => __( 'Search term for site name or URL.', 'mainwp' ),
280 'default' => '',
281 ),
282 'client_id' => array(
283 'type' => 'integer',
284 'description' => __( 'Filter by client ID.', 'mainwp' ),
285 'minimum' => 1,
286 ),
287 'tag_id' => array(
288 'type' => 'integer',
289 'description' => __( 'Filter by tag/group ID.', 'mainwp' ),
290 'minimum' => 1,
291 ),
292 ),
293 'additionalProperties' => false,
294 );
295 }
296
297 /**
298 * Get input schema for get-site-v1.
299 *
300 * Note: We use type: ["integer", "string"] instead of oneOf because JSON Schema
301 * validators fail when a numeric string like "123" matches multiple oneOf branches.
302 *
303 * @return array
304 */
305 private static function get_get_site_input_schema(): array {
306 return array(
307 'type' => 'object',
308 'properties' => array(
309 'site_id_or_domain' => array(
310 'type' => array( 'integer', 'string' ),
311 'description' => __( 'Site ID (integer) or domain/URL (string).', 'mainwp' ),
312 ),
313 'include_stats' => array(
314 'type' => 'boolean',
315 'description' => __( 'Include site statistics (updates count, health, etc.).', 'mainwp' ),
316 'default' => false,
317 ),
318 ),
319 'required' => array( 'site_id_or_domain' ),
320 'additionalProperties' => false,
321 );
322 }
323
324 /**
325 * Get input schema for sync-sites-v1.
326 *
327 * @return array
328 */
329 private static function get_sync_sites_input_schema(): array {
330 return array(
331 'type' => array( 'object', 'null' ),
332 'properties' => array(
333 'site_ids_or_domains' => array(
334 'type' => 'array',
335 'description' => __( 'Site IDs or domains to sync. Empty array means all sites.', 'mainwp' ),
336 'items' => array(
337 'type' => array( 'integer', 'string' ),
338 ),
339 'default' => array(),
340 ),
341 'site_ids' => array(
342 'type' => 'array',
343 'description' => __( 'Site IDs to sync. Empty array means all sites.', 'mainwp' ),
344 'items' => array(
345 'type' => 'integer',
346 'minimum' => 1,
347 ),
348 'default' => array(),
349 ),
350 'exclude_ids' => array(
351 'type' => 'array',
352 'description' => __( 'Site IDs to exclude from sync.', 'mainwp' ),
353 'items' => array(
354 'type' => 'integer',
355 'minimum' => 1,
356 ),
357 'default' => array(),
358 ),
359 ),
360 'additionalProperties' => false,
361 );
362 }
363
364 /**
365 * Get input schema for get-site-plugins-v1.
366 *
367 * @return array
368 */
369 private static function get_site_plugins_input_schema(): array {
370 return array(
371 'type' => 'object',
372 'properties' => array(
373 'site_id_or_domain' => array(
374 'type' => array( 'integer', 'string' ),
375 'description' => __( 'Site ID or domain/URL.', 'mainwp' ),
376 ),
377 'status' => array(
378 'type' => 'string',
379 'enum' => array( 'all', 'active', 'inactive' ),
380 'default' => 'all',
381 'description' => __( 'Filter by plugin status.', 'mainwp' ),
382 ),
383 'has_update' => array(
384 'type' => 'boolean',
385 'description' => __( 'Filter to only plugins with available updates.', 'mainwp' ),
386 ),
387 ),
388 'required' => array( 'site_id_or_domain' ),
389 'additionalProperties' => false,
390 );
391 }
392
393 /**
394 * Get input schema for get-site-themes-v1.
395 *
396 * @return array
397 */
398 private static function get_site_themes_input_schema(): array {
399 return array(
400 'type' => 'object',
401 'properties' => array(
402 'site_id_or_domain' => array(
403 'type' => array( 'integer', 'string' ),
404 'description' => __( 'Site ID or domain/URL.', 'mainwp' ),
405 ),
406 'status' => array(
407 'type' => 'string',
408 'enum' => array( 'all', 'active', 'inactive' ),
409 'default' => 'all',
410 'description' => __( 'Filter by theme status.', 'mainwp' ),
411 ),
412 'has_update' => array(
413 'type' => 'boolean',
414 'description' => __( 'Filter to only themes with available updates.', 'mainwp' ),
415 ),
416 ),
417 'required' => array( 'site_id_or_domain' ),
418 'additionalProperties' => false,
419 );
420 }
421
422 // =========================================================================
423 // Output Schema Definitions
424 // =========================================================================
425
426 /**
427 * Get output schema for list-sites-v1.
428 *
429 * @return array
430 */
431 public static function get_list_sites_output_schema(): array {
432 return array(
433 'type' => 'object',
434 'properties' => array(
435 'items' => array(
436 'type' => 'array',
437 'description' => __( 'List of sites.', 'mainwp' ),
438 'items' => array(
439 'type' => 'object',
440 'properties' => array(
441 'id' => array(
442 'type' => 'integer',
443 'description' => __( 'MainWP site ID.', 'mainwp' ),
444 ),
445 'url' => array(
446 'type' => 'string',
447 'format' => 'uri',
448 'description' => __( 'Site URL.', 'mainwp' ),
449 ),
450 'name' => array(
451 'type' => 'string',
452 'description' => __( 'Site name or label.', 'mainwp' ),
453 ),
454 'status' => array(
455 'type' => 'string',
456 'enum' => array( 'connected', 'disconnected', 'suspended' ),
457 'description' => __( 'Connection status.', 'mainwp' ),
458 ),
459 'client_id' => array(
460 'oneOf' => array(
461 array(
462 'type' => 'integer',
463 'minimum' => 1,
464 ),
465 array( 'type' => 'null' ),
466 ),
467 'description' => __( 'Associated client ID if any.', 'mainwp' ),
468 ),
469 ),
470 'required' => array( 'id', 'url', 'name', 'status' ),
471 'additionalProperties' => false,
472 ),
473 ),
474 'page' => array(
475 'type' => 'integer',
476 'description' => __( 'Current page number.', 'mainwp' ),
477 ),
478 'per_page' => array(
479 'type' => 'integer',
480 'description' => __( 'Items per page.', 'mainwp' ),
481 ),
482 'total' => array(
483 'type' => 'integer',
484 'description' => __( 'Total number of sites matching filters.', 'mainwp' ),
485 ),
486 ),
487 'required' => array( 'items', 'page', 'per_page', 'total' ),
488 );
489 }
490
491 /**
492 * Get output schema for get-site-v1.
493 *
494 * @return array
495 */
496 private static function get_site_output_schema(): array {
497 return array(
498 'type' => 'object',
499 'properties' => array(
500 'id' => array(
501 'type' => 'integer',
502 'description' => __( 'MainWP site ID.', 'mainwp' ),
503 ),
504 'url' => array(
505 'type' => 'string',
506 'format' => 'uri',
507 'description' => __( 'Site URL.', 'mainwp' ),
508 ),
509 'name' => array(
510 'type' => 'string',
511 'description' => __( 'Site name or label.', 'mainwp' ),
512 ),
513 'status' => array(
514 'type' => 'string',
515 'enum' => array( 'connected', 'disconnected', 'suspended' ),
516 'description' => __( 'Connection status.', 'mainwp' ),
517 ),
518 'client_id' => array(
519 'oneOf' => array(
520 array(
521 'type' => 'integer',
522 'minimum' => 1,
523 ),
524 array( 'type' => 'null' ),
525 ),
526 'description' => __( 'Associated client ID.', 'mainwp' ),
527 ),
528 'wp_version' => array(
529 'type' => 'string',
530 'description' => __( 'WordPress version.', 'mainwp' ),
531 ),
532 'php_version' => array(
533 'type' => 'string',
534 'description' => __( 'PHP version.', 'mainwp' ),
535 ),
536 'last_sync' => array(
537 'oneOf' => array(
538 array(
539 'type' => 'string',
540 'format' => 'date-time',
541 ),
542 array( 'type' => 'null' ),
543 ),
544 'description' => __( 'Last successful sync timestamp (ISO 8601).', 'mainwp' ),
545 ),
546 'admin_username' => array(
547 'type' => 'string',
548 'description' => __( 'Admin username for child site.', 'mainwp' ),
549 ),
550 'child_version' => array(
551 'type' => 'string',
552 'description' => __( 'MainWP Child plugin version.', 'mainwp' ),
553 ),
554 'notes' => array(
555 'type' => 'string',
556 'description' => __( 'Site notes.', 'mainwp' ),
557 ),
558 'stats' => array(
559 'type' => 'object',
560 'description' => __( 'Site statistics (only if include_stats=true).', 'mainwp' ),
561 'properties' => array(
562 'plugin_updates' => array( 'type' => 'integer' ),
563 'theme_updates' => array( 'type' => 'integer' ),
564 'wp_update_available' => array( 'type' => 'boolean' ),
565 'health_score' => array(
566 'oneOf' => array(
567 array(
568 'type' => 'integer',
569 'minimum' => 0,
570 'maximum' => 100,
571 ),
572 array( 'type' => 'null' ),
573 ),
574 ),
575 ),
576 ),
577 ),
578 'required' => array( 'id', 'url', 'name', 'status' ),
579 );
580 }
581
582 /**
583 * Get output schema for sync-sites-v1.
584 *
585 * @return array
586 */
587 private static function get_sync_output_schema(): array {
588 return array(
589 'type' => 'object',
590 'properties' => array(
591 // Immediate execution response (≤ threshold sites).
592 'synced' => array(
593 'type' => 'array',
594 'description' => __( 'Sites successfully synced (immediate mode only).', 'mainwp' ),
595 'items' => array(
596 'type' => 'object',
597 'properties' => array(
598 'id' => array( 'type' => 'integer' ),
599 'url' => array(
600 'type' => 'string',
601 'format' => 'uri',
602 ),
603 'name' => array( 'type' => 'string' ),
604 ),
605 'required' => array( 'id', 'url', 'name' ),
606 ),
607 ),
608 'errors' => array(
609 'type' => 'array',
610 'description' => __( 'Sites that failed to sync (immediate mode only).', 'mainwp' ),
611 'items' => array(
612 'type' => 'object',
613 'properties' => array(
614 'identifier' => array(
615 'oneOf' => array(
616 array( 'type' => 'integer' ),
617 array( 'type' => 'string' ),
618 ),
619 ),
620 'code' => array( 'type' => 'string' ),
621 'message' => array( 'type' => 'string' ),
622 ),
623 'required' => array( 'identifier', 'code', 'message' ),
624 ),
625 ),
626 'total_synced' => array(
627 'type' => 'integer',
628 'description' => __( 'Number of sites successfully synced.', 'mainwp' ),
629 ),
630 'total_errors' => array(
631 'type' => 'integer',
632 'description' => __( 'Number of sites that failed to sync.', 'mainwp' ),
633 ),
634 // Queued execution response (> threshold sites).
635 'queued' => array(
636 'type' => 'boolean',
637 'description' => __( 'Whether the operation was queued for background processing.', 'mainwp' ),
638 ),
639 'job_id' => array(
640 'type' => 'string',
641 'description' => __( 'Background job ID for status polling (only when queued=true).', 'mainwp' ),
642 ),
643 'status_url' => array(
644 'type' => 'string',
645 'format' => 'uri',
646 'description' => __( 'URL to poll for job status (only when queued=true).', 'mainwp' ),
647 ),
648 'sites_queued' => array(
649 'type' => 'integer',
650 'description' => __( 'Number of sites queued for sync (only when queued=true).', 'mainwp' ),
651 ),
652 ),
653 // Note: Either (synced, errors, total_synced, total_errors) OR (queued, job_id, status_url, sites_queued) will be present.
654 'required' => array(),
655 );
656 }
657
658 /**
659 * Get output schema for get-site-plugins-v1.
660 *
661 * @return array
662 */
663 private static function get_plugins_output_schema(): array {
664 return array(
665 'type' => 'object',
666 'properties' => array(
667 'site_id' => array(
668 'type' => 'integer',
669 'description' => __( 'MainWP site ID.', 'mainwp' ),
670 ),
671 'site_url' => array(
672 'type' => 'string',
673 'format' => 'uri',
674 'description' => __( 'Site URL.', 'mainwp' ),
675 ),
676 'plugins' => array(
677 'type' => 'array',
678 'description' => __( 'List of plugins.', 'mainwp' ),
679 'items' => array(
680 'type' => 'object',
681 'properties' => array(
682 'slug' => array( 'type' => 'string' ),
683 'name' => array( 'type' => 'string' ),
684 'version' => array( 'type' => 'string' ),
685 'active' => array( 'type' => 'boolean' ),
686 'update_version' => array(
687 'oneOf' => array(
688 array( 'type' => 'string' ),
689 array( 'type' => 'null' ),
690 ),
691 ),
692 ),
693 'required' => array( 'slug', 'name', 'version', 'active' ),
694 ),
695 ),
696 'total' => array(
697 'type' => 'integer',
698 'description' => __( 'Total number of plugins.', 'mainwp' ),
699 ),
700 ),
701 'required' => array( 'site_id', 'site_url', 'plugins', 'total' ),
702 );
703 }
704
705 /**
706 * Get output schema for get-site-themes-v1.
707 *
708 * @return array
709 */
710 private static function get_themes_output_schema(): array {
711 return array(
712 'type' => 'object',
713 'properties' => array(
714 'site_id' => array(
715 'type' => 'integer',
716 'description' => __( 'MainWP site ID.', 'mainwp' ),
717 ),
718 'site_url' => array(
719 'type' => 'string',
720 'format' => 'uri',
721 'description' => __( 'Site URL.', 'mainwp' ),
722 ),
723 'active_theme' => array(
724 'type' => 'string',
725 'description' => __( 'Currently active theme slug.', 'mainwp' ),
726 ),
727 'themes' => array(
728 'type' => 'array',
729 'description' => __( 'List of themes.', 'mainwp' ),
730 'items' => array(
731 'type' => 'object',
732 'properties' => array(
733 'slug' => array( 'type' => 'string' ),
734 'name' => array( 'type' => 'string' ),
735 'version' => array( 'type' => 'string' ),
736 'active' => array( 'type' => 'boolean' ),
737 'update_version' => array(
738 'oneOf' => array(
739 array( 'type' => 'string' ),
740 array( 'type' => 'null' ),
741 ),
742 ),
743 ),
744 'required' => array( 'slug', 'name', 'version', 'active' ),
745 ),
746 ),
747 'total' => array(
748 'type' => 'integer',
749 'description' => __( 'Total number of themes.', 'mainwp' ),
750 ),
751 ),
752 'required' => array( 'site_id', 'site_url', 'active_theme', 'themes', 'total' ),
753 );
754 }
755
756 // =========================================================================
757 // Execute Callbacks
758 // =========================================================================
759
760 /**
761 * Execute callback for mainwp/list-sites-v1.
762 *
763 * @param array|null $input Validated input from Abilities API.
764 * @return array|\WP_Error
765 */
766 public static function execute_list_sites( $input ) {
767 $input = is_array( $input ) ? $input : array();
768 $db = MainWP_DB::instance();
769
770 // Map ability input to DB method parameters.
771 $db_params = array(
772 'paged' => $input['page'] ?? 1,
773 'items_per_page' => $input['per_page'] ?? 20,
774 's' => $input['search'] ?? '',
775 // Include status-related fields for format_site_for_output().
776 'fields' => array( 'suspended', 'offline_check_result', 'sync_errors' ),
777 );
778
779 // Status mapping: 'any' means no filter, otherwise wrap in array.
780 // Note: 'connected' filter adds 'unsuspended' to exclude suspended sites,
781 // ensuring returned items have consistent status='connected' in output.
782 $status = $input['status'] ?? 'any';
783 if ( 'any' !== $status ) {
784 $db_status = array( $status );
785 // When filtering for 'connected', also exclude suspended sites.
786 if ( 'connected' === $status ) {
787 $db_status[] = 'unsuspended';
788 }
789 $db_params['status'] = $db_status;
790 }
791
792 // Client filter.
793 if ( isset( $input['client_id'] ) ) {
794 $db_params['client'] = (string) $input['client_id'];
795 }
796
797 // Tag/group filter (map tag_id to group_id for DB).
798 if ( isset( $input['tag_id'] ) ) {
799 $db_params['group_id'] = $input['tag_id'];
800 }
801
802 // Get total count using efficient COUNT(*) query.
803 $count_filters = array(
804 'status' => ( 'any' !== $status ) ? $status : '',
805 'tags' => isset( $input['tag_id'] ) ? array( (int) $input['tag_id'] ) : array(),
806 'client_id' => isset( $input['client_id'] ) ? (int) $input['client_id'] : 0,
807 's' => $input['search'] ?? '',
808 );
809 $total = $db->get_websites_count_for_current_user( $count_filters );
810
811 // Now get the paginated subset.
812 $websites = $db->get_websites_for_current_user( $db_params );
813
814 if ( is_wp_error( $websites ) ) {
815 return $websites;
816 }
817
818 // Map DB records to output schema shape.
819 $items = array();
820 if ( $websites ) {
821 foreach ( $websites as $site ) {
822 $items[] = MainWP_Abilities_Util::format_site_for_output( $site );
823 }
824 }
825
826 return array(
827 'items' => $items,
828 'page' => (int) ( $input['page'] ?? 1 ),
829 'per_page' => (int) ( $input['per_page'] ?? 20 ),
830 'total' => (int) $total,
831 );
832 }
833
834 /**
835 * Execute callback for mainwp/get-site-v1.
836 *
837 * NOTE: Site resolution and ACL check are performed in permission_callback
838 * via make_site_permission_callback(). If we reach this execute callback,
839 * permissions have already been verified.
840 *
841 * @param array $input Validated input from Abilities API.
842 * @return array|\WP_Error
843 */
844 public static function execute_get_site( array $input ) {
845 $site = MainWP_Abilities_Util::resolve_site( $input['site_id_or_domain'] );
846
847 if ( is_wp_error( $site ) ) {
848 // This shouldn't happen if permission_callback passed, but handle gracefully.
849 return $site;
850 }
851
852 $include_stats = $input['include_stats'] ?? false;
853
854 return MainWP_Abilities_Util::format_site_for_output( $site, true, $include_stats );
855 }
856
857 /**
858 * Execute callback for mainwp/sync-sites-v1.
859 *
860 * @param array|null $input Validated input from Abilities API.
861 * @return array|\WP_Error
862 */
863 public static function execute_sync_sites( $input ) { // phpcs:ignore -- NOSONAR - complexity method.
864 $input = is_array( $input ) ? $input : array();
865
866 // Core schema validation coerce-accepts scalars for type:array but hands the raw
867 // value through; a non-array must not fall into the all-sites branch below.
868 foreach ( array( 'site_ids', 'site_ids_or_domains', 'exclude_ids' ) as $array_key ) {
869 if ( array_key_exists( $array_key, $input ) && ! is_array( $input[ $array_key ] ) ) {
870 return new \WP_Error(
871 'mainwp_invalid_input',
872 __( 'The site_ids, site_ids_or_domains, and exclude_ids parameters must be arrays of site IDs or domains.', 'mainwp' ),
873 array( 'status' => 400 )
874 );
875 }
876 }
877
878 $site_ids = isset( $input['site_ids'] ) && is_array( $input['site_ids'] )
879 ? array_values( array_filter( array_map( 'absint', $input['site_ids'] ) ) )
880 : array();
881
882 // A provided list whose ids all filter out (e.g. [0]) must not net out to
883 // the all-sites branch. Schema minimum:1 covers validated paths; this covers
884 // direct callback invocation.
885 if ( ! empty( $input['site_ids'] ) && empty( $site_ids ) ) {
886 return new \WP_Error(
887 'mainwp_invalid_input',
888 __( 'The site_ids parameter contains no valid site IDs.', 'mainwp' ),
889 array( 'status' => 400 )
890 );
891 }
892
893 $site_ids_or_domains = isset( $input['site_ids_or_domains'] ) && is_array( $input['site_ids_or_domains'] )
894 ? $input['site_ids_or_domains']
895 : array();
896 if ( empty( $site_ids_or_domains ) && ! empty( $site_ids ) ) {
897 $site_ids_or_domains = $site_ids;
898 }
899
900 $exclude_ids = ! empty( $input['exclude_ids'] ) && is_array( $input['exclude_ids'] )
901 ? array_map( 'absint', $input['exclude_ids'] )
902 : array();
903 $exclude_set = $exclude_ids ? array_fill_keys( $exclude_ids, true ) : array();
904
905 // If empty, get all sites for current user.
906 if ( empty( $site_ids_or_domains ) ) {
907 $all_sites = MainWP_DB::instance()->get_websites_for_current_user( array( 'selectgroups' => false ) );
908
909 // Handle potential errors from DB query.
910 if ( is_wp_error( $all_sites ) ) {
911 return $all_sites;
912 }
913
914 $site_ids_or_domains = array();
915 if ( ! empty( $all_sites ) ) {
916 foreach ( $all_sites as $s ) {
917 $id = (int) $s->id;
918 if ( empty( $exclude_set ) || ! isset( $exclude_set[ $id ] ) ) {
919 $site_ids_or_domains[] = $id;
920 }
921 }
922 }
923 } elseif ( ! empty( $exclude_set ) ) {
924 // Filter numeric IDs early; domains/URLs are left as-is.
925 $filtered = array();
926 foreach ( $site_ids_or_domains as $identifier ) {
927 if ( is_numeric( $identifier ) ) {
928 $id = (int) $identifier;
929 if ( isset( $exclude_set[ $id ] ) ) {
930 continue;
931 }
932 $filtered[] = $id;
933 } else {
934 $filtered[] = $identifier;
935 }
936 }
937 $site_ids_or_domains = $filtered;
938 }
939
940 // Check per-site ACLs and filter to allowed sites.
941 $access_check = MainWP_Abilities_Util::check_batch_site_access( $site_ids_or_domains, $input );
942
943 // Queue if > threshold sites.
944 $threshold = apply_filters( 'mainwp_abilities_batch_threshold', 200 );
945 if ( count( $access_check['allowed'] ) > $threshold ) {
946 $job_id = MainWP_Abilities_Util::queue_batch_sync( $access_check['allowed'] );
947
948 // Handle queue failure.
949 if ( is_wp_error( $job_id ) ) {
950 return $job_id;
951 }
952
953 return array(
954 'queued' => true,
955 'job_id' => $job_id,
956 'status_url' => rest_url( "mainwp/v2/jobs/{$job_id}" ),
957 'sites_queued' => count( $access_check['allowed'] ),
958 );
959 }
960
961 // Immediate execution for ≤ threshold sites.
962 $synced = array();
963 $errors = $access_check['denied']; // Start with ACL-denied sites.
964
965 foreach ( $access_check['allowed'] as $site ) {
966 // Check if site is known offline before attempting sync.
967 if ( isset( $site->offline_check_result ) && -1 === (int) $site->offline_check_result ) {
968 $errors[] = array(
969 'identifier' => (int) $site->id,
970 'code' => 'mainwp_site_offline',
971 'message' => __( 'Site is known to be offline.', 'mainwp' ),
972 );
973 continue;
974 }
975
976 // Check child version before sync.
977 $version_check = MainWP_Abilities_Util::check_child_version( $site );
978 if ( is_wp_error( $version_check ) ) {
979 $errors[] = array(
980 'identifier' => (int) $site->id,
981 'code' => $version_check->get_error_code(),
982 'message' => $version_check->get_error_message(),
983 );
984 continue;
985 }
986
987 // Perform sync using MainWP_Sync class.
988 try {
989 $result = MainWP_Sync::sync_site( $site );
990
991 // Allow filtering of sync result for testing/extension purposes.
992 $result = apply_filters( 'mainwp_sync_site_result', $result, (int) $site->id );
993
994 if ( is_wp_error( $result ) ) {
995 $errors[] = array(
996 'identifier' => (int) $site->id,
997 'code' => $result->get_error_code(),
998 'message' => $result->get_error_message(),
999 );
1000 } elseif ( false === $result ) {
1001 $errors[] = array(
1002 'identifier' => (int) $site->id,
1003 'code' => 'mainwp_sync_failed',
1004 'message' => __( 'Sync operation failed.', 'mainwp' ),
1005 );
1006 } else {
1007 $synced[] = array(
1008 'id' => (int) $site->id,
1009 'url' => $site->url,
1010 'name' => MainWP_Utility::remove_http_prefix( (string) $site->name, true ),
1011 );
1012 }
1013 } catch ( \Exception $e ) {
1014 $errors[] = array(
1015 'identifier' => (int) $site->id,
1016 'code' => 'mainwp_sync_exception',
1017 'message' => $e->getMessage(),
1018 );
1019 }
1020 }
1021
1022 return array(
1023 'queued' => false,
1024 'synced' => $synced,
1025 'errors' => $errors,
1026 'total_synced' => count( $synced ),
1027 'total_errors' => count( $errors ),
1028 );
1029 }
1030
1031 /**
1032 * Execute callback for mainwp/get-site-plugins-v1.
1033 *
1034 * @param array $input Validated input from Abilities API.
1035 * @return array|\WP_Error
1036 */
1037 public static function execute_get_site_plugins( array $input ) { // phpcs:ignore -- NOSONAR - complexity method.
1038 $site = MainWP_Abilities_Util::resolve_site( $input['site_id_or_domain'] );
1039
1040 if ( is_wp_error( $site ) ) {
1041 return $site;
1042 }
1043
1044 // ACL check is done in permission_callback, but double-check for safety.
1045 if ( ! MainWP_Abilities_Util::can_access_site( $site, $input ) ) {
1046 return new \WP_Error(
1047 'mainwp_access_denied',
1048 __( 'You do not have permission to access this site.', 'mainwp' ),
1049 array( 'status' => 403 )
1050 );
1051 }
1052
1053 // Check child version for plugin data.
1054 $version_check = MainWP_Abilities_Util::check_child_version( $site );
1055 if ( is_wp_error( $version_check ) ) {
1056 return $version_check;
1057 }
1058
1059 // Get plugins from site data (stored as JSON).
1060 $plugins_data = ! empty( $site->plugins ) ? json_decode( $site->plugins, true ) : array();
1061 if ( ! is_array( $plugins_data ) ) {
1062 $plugins_data = array();
1063 }
1064
1065 // Get plugin updates for this site.
1066 $plugin_updates = ! empty( $site->plugin_upgrades ) ? json_decode( $site->plugin_upgrades, true ) : array();
1067 if ( ! is_array( $plugin_updates ) ) {
1068 $plugin_updates = array();
1069 }
1070
1071 $status_filter = $input['status'] ?? 'all';
1072 $has_update_filter = $input['has_update'] ?? null;
1073
1074 $plugins = array();
1075 foreach ( $plugins_data as $key => $plugin ) {
1076 // Support both formats:
1077 // - Associative array: slug is the key (e.g., 'akismet/akismet.php' => [...]).
1078 // - Indexed array: slug is inside the plugin data (e.g., 0 => ['slug' => 'akismet/akismet.php', ...]).
1079 $slug = is_string( $key ) && ! empty( $key ) ? $key : ( $plugin['slug'] ?? '' );
1080
1081 // Skip plugins with invalid slugs.
1082 if ( empty( $slug ) || ! is_string( $slug ) ) {
1083 continue;
1084 }
1085
1086 $is_active = ! empty( $plugin['active'] );
1087
1088 // Apply status filter.
1089 if ( 'active' === $status_filter && ! $is_active ) {
1090 continue;
1091 }
1092 if ( 'inactive' === $status_filter && $is_active ) {
1093 continue;
1094 }
1095
1096 $has_update = isset( $plugin_updates[ $slug ] );
1097
1098 // Apply has_update filter if specified.
1099 if ( null !== $has_update_filter && $has_update_filter && ! $has_update ) {
1100 continue;
1101 }
1102
1103 // Extract update version from plugin_upgrades structure.
1104 $update_version = null;
1105 if ( $has_update && isset( $plugin_updates[ $slug ]['update']['new_version'] ) ) {
1106 $update_version = $plugin_updates[ $slug ]['update']['new_version'];
1107 } elseif ( $has_update && isset( $plugin_updates[ $slug ]['new_version'] ) ) {
1108 // Alternative structure.
1109 $update_version = $plugin_updates[ $slug ]['new_version'];
1110 }
1111
1112 $plugins[] = array(
1113 'slug' => $slug,
1114 'name' => $plugin['name'] ?? $slug,
1115 'version' => $plugin['version'] ?? '',
1116 'active' => $is_active,
1117 'update_version' => $update_version,
1118 );
1119 }
1120
1121 return array(
1122 'site_id' => (int) $site->id,
1123 'site_url' => $site->url,
1124 'plugins' => $plugins,
1125 'total' => count( $plugins ),
1126 );
1127 }
1128
1129 /**
1130 * Execute callback for mainwp/get-site-themes-v1.
1131 *
1132 * @param array $input Validated input from Abilities API.
1133 * @return array|\WP_Error
1134 */
1135 public static function execute_get_site_themes( array $input ) { // phpcs:ignore -- NOSONAR - complexity method.
1136 $site = MainWP_Abilities_Util::resolve_site( $input['site_id_or_domain'] );
1137
1138 if ( is_wp_error( $site ) ) {
1139 return $site;
1140 }
1141
1142 // ACL check is done in permission_callback, but double-check for safety.
1143 if ( ! MainWP_Abilities_Util::can_access_site( $site, $input ) ) {
1144 return new \WP_Error(
1145 'mainwp_access_denied',
1146 __( 'You do not have permission to access this site.', 'mainwp' ),
1147 array( 'status' => 403 )
1148 );
1149 }
1150
1151 // Check child version for theme data.
1152 $version_check = MainWP_Abilities_Util::check_child_version( $site );
1153 if ( is_wp_error( $version_check ) ) {
1154 return $version_check;
1155 }
1156
1157 // Get themes from site data (stored as JSON).
1158 $themes_data = ! empty( $site->themes ) ? json_decode( $site->themes, true ) : array();
1159 if ( ! is_array( $themes_data ) ) {
1160 $themes_data = array();
1161 }
1162
1163 // Get theme updates for this site.
1164 $theme_updates = ! empty( $site->theme_upgrades ) ? json_decode( $site->theme_upgrades, true ) : array();
1165 if ( ! is_array( $theme_updates ) ) {
1166 $theme_updates = array();
1167 }
1168
1169 // Determine active theme - check for 'active' flag in theme data.
1170 // Support both associative (slug as key) and indexed (slug inside theme) formats.
1171 $active_theme_slug = '';
1172 foreach ( $themes_data as $key => $theme ) {
1173 $theme_slug = is_string( $key ) && ! empty( $key ) ? $key : ( $theme['slug'] ?? '' );
1174 if ( ! empty( $theme['active'] ) && ! empty( $theme_slug ) ) {
1175 $active_theme_slug = $theme_slug;
1176 break;
1177 }
1178 }
1179
1180 $status_filter = $input['status'] ?? 'all';
1181 $has_update_filter = $input['has_update'] ?? null;
1182
1183 $themes = array();
1184 foreach ( $themes_data as $key => $theme ) {
1185 // Support both formats:
1186 // - Associative array: slug is the key (e.g., 'twentytwentyfour' => [...]).
1187 // - Indexed array: slug is inside the theme data (e.g., 0 => ['slug' => 'twentytwentyfour', ...]).
1188 $slug = is_string( $key ) && ! empty( $key ) ? $key : ( $theme['slug'] ?? '' );
1189
1190 // Skip themes with invalid slugs.
1191 if ( empty( $slug ) || ! is_string( $slug ) ) {
1192 continue;
1193 }
1194
1195 $is_active = ( $slug === $active_theme_slug );
1196
1197 // Apply status filter.
1198 if ( 'active' === $status_filter && ! $is_active ) {
1199 continue;
1200 }
1201 if ( 'inactive' === $status_filter && $is_active ) {
1202 continue;
1203 }
1204
1205 $has_update = isset( $theme_updates[ $slug ] );
1206
1207 // Apply has_update filter if specified.
1208 if ( null !== $has_update_filter && $has_update_filter && ! $has_update ) {
1209 continue;
1210 }
1211
1212 // Extract update version from theme_upgrades structure.
1213 $update_version = null;
1214 if ( $has_update && isset( $theme_updates[ $slug ]['update']['new_version'] ) ) {
1215 $update_version = $theme_updates[ $slug ]['update']['new_version'];
1216 } elseif ( $has_update && isset( $theme_updates[ $slug ]['new_version'] ) ) {
1217 // Alternative structure.
1218 $update_version = $theme_updates[ $slug ]['new_version'];
1219 }
1220
1221 $themes[] = array(
1222 'slug' => $slug,
1223 'name' => $theme['name'] ?? $slug,
1224 'version' => $theme['version'] ?? '',
1225 'active' => $is_active,
1226 'update_version' => $update_version,
1227 );
1228 }
1229
1230 return array(
1231 'site_id' => (int) $site->id,
1232 'site_url' => $site->url,
1233 'active_theme' => $active_theme_slug,
1234 'themes' => $themes,
1235 'total' => count( $themes ),
1236 );
1237 }
1238 // =========================================================================
1239 // Connection Management Abilities (8)
1240 // =========================================================================
1241
1242 /**
1243 * Register mainwp/add-site-v1 ability.
1244 *
1245 * @return void
1246 */
1247 private static function register_add_site(): void {
1248 wp_register_ability(
1249 'mainwp/add-site-v1',
1250 array(
1251 'label' => __( 'Add MainWP Site', 'mainwp' ),
1252 'description' => __( 'Add a new MainWP child site.', 'mainwp' ),
1253 'category' => 'mainwp-sites',
1254 'input_schema' => self::get_add_site_input_schema(),
1255 'output_schema' => self::get_site_output_schema(),
1256 'execute_callback' => array( self::class, 'execute_add_site' ),
1257 'permission_callback' => array( MainWP_Abilities_Util::class, 'check_manage_sites_permission' ),
1258 'meta' => array(
1259 'show_in_rest' => true,
1260 'annotations' => array(
1261 'instructions' => '',
1262 'readonly' => false,
1263 'destructive' => false,
1264 'idempotent' => false,
1265 ),
1266 ),
1267 )
1268 );
1269 }
1270
1271 /**
1272 * Get input schema for add-site-v1.
1273 *
1274 * @return array JSON Schema.
1275 */
1276 private static function get_add_site_input_schema(): array {
1277 return array(
1278 'type' => 'object',
1279 'required' => array( 'url', 'name', 'admin_username' ),
1280 'additionalProperties' => false,
1281 'properties' => array(
1282 'url' => array(
1283 'type' => 'string',
1284 'description' => __( 'Site URL', 'mainwp' ),
1285 ),
1286 'name' => array(
1287 'type' => 'string',
1288 'description' => __( 'Site name', 'mainwp' ),
1289 ),
1290 'admin_username' => array(
1291 'type' => 'string',
1292 'description' => __( 'Admin username', 'mainwp' ),
1293 ),
1294 'verify_certificate' => array(
1295 'type' => 'integer',
1296 'description' => __( 'SSL verification (0=off, 1=on, 2=global)', 'mainwp' ),
1297 'default' => 1,
1298 'enum' => array( 0, 1, 2 ),
1299 ),
1300 'ssl_version' => array(
1301 'type' => 'integer',
1302 'description' => __( 'SSL version', 'mainwp' ),
1303 'default' => 0,
1304 ),
1305 'http_user' => array(
1306 'type' => 'string',
1307 'description' => __( 'HTTP auth username', 'mainwp' ),
1308 ),
1309 'http_pass' => array(
1310 'type' => 'string',
1311 'description' => __( 'HTTP auth password', 'mainwp' ),
1312 ),
1313 'tag_ids' => array(
1314 'type' => 'array',
1315 'description' => __( 'Tag IDs to assign', 'mainwp' ),
1316 'items' => array(
1317 'type' => 'integer',
1318 ),
1319 ),
1320 'client_id' => array(
1321 'type' => 'integer',
1322 'description' => __( 'Client ID to assign', 'mainwp' ),
1323 ),
1324 'adminpassword' => array(
1325 'type' => 'string',
1326 'description' => __( 'WordPress admin password (optional, for sites requiring password authentication)', 'mainwp' ),
1327 ),
1328 'uniqueid' => array(
1329 'type' => 'string',
1330 'description' => __( 'Unique Security ID configured in MainWP Child plugin', 'mainwp' ),
1331 ),
1332 ),
1333 );
1334 }
1335
1336 /**
1337 * Execute add-site-v1 ability.
1338 *
1339 * @param array $input Input parameters.
1340 * @return array|\WP_Error Site data or error.
1341 */
1342 public static function execute_add_site( $input ) { // phpcs:ignore -- NOSONAR - complexity method.
1343 $input = MainWP_Abilities_Util::normalize_input( $input );
1344
1345 // Validate required fields.
1346 $url = isset( $input['url'] ) ? sanitize_text_field( $input['url'] ) : '';
1347 $name = isset( $input['name'] ) ? sanitize_text_field( $input['name'] ) : '';
1348 $admin_username = isset( $input['admin_username'] ) ? sanitize_text_field( $input['admin_username'] ) : '';
1349
1350 if ( empty( $url ) || empty( $name ) || empty( $admin_username ) ) {
1351 return new \WP_Error(
1352 'mainwp_invalid_input',
1353 __( 'Missing required fields: url, name, or admin_username.', 'mainwp' ),
1354 array( 'status' => 400 )
1355 );
1356 }
1357
1358 // Validate URL format.
1359 if ( ! filter_var( $url, FILTER_VALIDATE_URL ) ) {
1360 return new \WP_Error(
1361 'mainwp_invalid_url',
1362 __( 'Invalid site URL format.', 'mainwp' ),
1363 array( 'status' => 400 )
1364 );
1365 }
1366
1367 // Normalize URL for consistent storage and duplicate detection.
1368 // This lowercases the host (per RFC 4343) and ensures trailing slash.
1369 $url = MainWP_Abilities_Util::normalize_site_url( $url );
1370
1371 // Check if site already exists.
1372 $existing = MainWP_DB::instance()->get_websites_by_url( $url );
1373 if ( ! empty( $existing ) ) {
1374 return new \WP_Error(
1375 'mainwp_site_already_exists',
1376 __( 'A site with this URL already exists.', 'mainwp' ),
1377 array( 'status' => 409 )
1378 );
1379 }
1380
1381 // Prepare site data.
1382 $site_data = array(
1383 'url' => $url,
1384 'name' => $name,
1385 'admin' => $admin_username,
1386 'ssl_verify' => isset( $input['verify_certificate'] ) ? (int) $input['verify_certificate'] : 1,
1387 'ssl_version' => isset( $input['ssl_version'] ) ? (int) $input['ssl_version'] : 0,
1388 );
1389
1390 if ( ! empty( $input['http_user'] ) ) {
1391 $site_data['http_user'] = sanitize_text_field( $input['http_user'] );
1392 }
1393 if ( ! empty( $input['http_pass'] ) ) {
1394 $site_data['http_pass'] = $input['http_pass'];
1395 }
1396 if ( ! empty( $input['adminpassword'] ) ) {
1397 $site_data['adminpassword'] = $input['adminpassword'];
1398 }
1399 if ( ! empty( $input['uniqueid'] ) ) {
1400 $site_data['uniqueid'] = sanitize_text_field( $input['uniqueid'] );
1401 }
1402
1403 // Add site using REST API method (not AJAX handler which calls die()).
1404 $result = MainWP_Manage_Sites_Handler::rest_api_add_site( $site_data );
1405
1406 // Handle error response from rest_api_add_site.
1407 if ( ! empty( $result['error'] ) ) {
1408 $message = $result['error'];
1409
1410 if ( strpos( $message, 'connection' ) !== false || strpos( $message, 'connect' ) !== false ) {
1411 return new \WP_Error( 'mainwp_connection_failed', $message, array( 'status' => 503 ) );
1412 }
1413 if ( strpos( $message, 'credentials' ) !== false || strpos( $message, 'invalid' ) !== false ) {
1414 return new \WP_Error( 'mainwp_invalid_credentials', $message, array( 'status' => 401 ) );
1415 }
1416
1417 // Generic error.
1418 return new \WP_Error( 'mainwp_site_add_failed', $message, array( 'status' => 500 ) );
1419 }
1420
1421 // Get the newly added site.
1422 $site_id = isset( $result['siteid'] ) ? (int) $result['siteid'] : 0;
1423 if ( empty( $site_id ) ) {
1424 return new \WP_Error(
1425 'mainwp_site_add_failed',
1426 __( 'Failed to add site.', 'mainwp' ),
1427 array( 'status' => 500 )
1428 );
1429 }
1430
1431 $site = MainWP_DB::instance()->get_website_by_id( $site_id );
1432 if ( empty( $site ) ) {
1433 return new \WP_Error(
1434 'mainwp_site_not_found',
1435 __( 'Site added but could not retrieve site data.', 'mainwp' ),
1436 array( 'status' => 500 )
1437 );
1438 }
1439
1440 // Assign tags if provided.
1441 if ( ! empty( $input['tag_ids'] ) && is_array( $input['tag_ids'] ) ) {
1442 foreach ( $input['tag_ids'] as $tag_id ) {
1443 MainWP_DB_Common::instance()->update_group_site( (int) $tag_id, $site_id );
1444 }
1445 }
1446
1447 // Assign client if provided.
1448 if ( ! empty( $input['client_id'] ) ) {
1449 MainWP_DB::instance()->update_website_values( $site_id, array( 'client_id' => (int) $input['client_id'] ) );
1450 }
1451
1452 // Re-fetch site to get updated data.
1453 $site = MainWP_DB::instance()->get_website_by_id( $site_id );
1454
1455 return MainWP_Abilities_Util::format_site_for_output( $site, true );
1456 }
1457
1458 /**
1459 * Register mainwp/update-site-v1 ability.
1460 *
1461 * @return void
1462 */
1463 private static function register_update_site(): void {
1464 wp_register_ability(
1465 'mainwp/update-site-v1',
1466 array(
1467 'label' => __( 'Update MainWP Site', 'mainwp' ),
1468 'description' => __( 'Update settings for a MainWP child site.', 'mainwp' ),
1469 'category' => 'mainwp-sites',
1470 'input_schema' => self::get_update_site_input_schema(),
1471 'output_schema' => self::get_site_output_schema(),
1472 'execute_callback' => array( self::class, 'execute_update_site' ),
1473 'permission_callback' => array( MainWP_Abilities_Util::class, 'check_manage_sites_permission' ),
1474 'meta' => array(
1475 'show_in_rest' => true,
1476 'annotations' => array(
1477 'instructions' => '',
1478 'readonly' => false,
1479 'destructive' => false,
1480 'idempotent' => true,
1481 ),
1482 ),
1483 )
1484 );
1485 }
1486
1487 /**
1488 * Get input schema for update-site-v1.
1489 *
1490 * @return array JSON Schema.
1491 */
1492 private static function get_update_site_input_schema(): array {
1493 return array(
1494 'type' => 'object',
1495 'required' => array( 'site_id_or_domain' ),
1496 'additionalProperties' => false,
1497 'properties' => array(
1498 'site_id_or_domain' => array(
1499 'type' => array( 'integer', 'string' ),
1500 'description' => __( 'Site ID or domain', 'mainwp' ),
1501 ),
1502 'name' => array(
1503 'type' => 'string',
1504 'description' => __( 'New site name', 'mainwp' ),
1505 ),
1506 'url' => array(
1507 'type' => 'string',
1508 'description' => __( 'New site URL', 'mainwp' ),
1509 ),
1510 'admin_username' => array(
1511 'type' => 'string',
1512 'description' => __( 'New admin username', 'mainwp' ),
1513 ),
1514 'verify_certificate' => array(
1515 'type' => 'integer',
1516 'description' => __( 'SSL verification (0=off, 1=on, 2=global)', 'mainwp' ),
1517 'enum' => array( 0, 1, 2 ),
1518 ),
1519 'ssl_version' => array(
1520 'type' => 'integer',
1521 'description' => __( 'SSL version', 'mainwp' ),
1522 ),
1523 'http_user' => array(
1524 'type' => 'string',
1525 'description' => __( 'HTTP auth username', 'mainwp' ),
1526 ),
1527 'http_pass' => array(
1528 'type' => 'string',
1529 'description' => __( 'HTTP auth password', 'mainwp' ),
1530 ),
1531 'tag_ids' => array(
1532 'type' => 'array',
1533 'description' => __( 'Tag IDs to assign', 'mainwp' ),
1534 'items' => array(
1535 'type' => 'integer',
1536 ),
1537 ),
1538 'client_id' => array(
1539 'type' => 'integer',
1540 'description' => __( 'Client ID to assign', 'mainwp' ),
1541 ),
1542 'suspended' => array(
1543 'type' => 'integer',
1544 'description' => __( 'Suspended status (0 or 1)', 'mainwp' ),
1545 'enum' => array( 0, 1 ),
1546 ),
1547 ),
1548 );
1549 }
1550
1551 /**
1552 * Execute update-site-v1 ability.
1553 *
1554 * @param array $input Input parameters.
1555 * @return array|\WP_Error Site data or error.
1556 */
1557 public static function execute_update_site( $input ) { // phpcs:ignore -- NOSONAR - complexity method.
1558 $input = MainWP_Abilities_Util::normalize_input( $input );
1559
1560 $site = MainWP_Abilities_Util::resolve_site( $input['site_id_or_domain'] ?? null );
1561 if ( is_wp_error( $site ) ) {
1562 return $site;
1563 }
1564
1565 $access_check = MainWP_Abilities_Util::check_site_access( $site );
1566 if ( is_wp_error( $access_check ) ) {
1567 return $access_check;
1568 }
1569
1570 $update_data = array();
1571
1572 if ( isset( $input['name'] ) ) {
1573 $update_data['name'] = sanitize_text_field( $input['name'] );
1574 }
1575 if ( isset( $input['url'] ) ) {
1576 $update_data['url'] = sanitize_text_field( $input['url'] );
1577 }
1578 if ( isset( $input['admin_username'] ) ) {
1579 $update_data['adminname'] = sanitize_text_field( $input['admin_username'] );
1580 }
1581 if ( isset( $input['verify_certificate'] ) ) {
1582 $update_data['verify_certificate'] = (int) $input['verify_certificate'];
1583 }
1584 if ( isset( $input['ssl_version'] ) ) {
1585 $update_data['ssl_version'] = (int) $input['ssl_version'];
1586 }
1587 if ( isset( $input['http_user'] ) ) {
1588 $update_data['http_user'] = sanitize_text_field( $input['http_user'] );
1589 }
1590 if ( isset( $input['http_pass'] ) ) {
1591 $update_data['http_pass'] = $input['http_pass'];
1592 }
1593 if ( isset( $input['suspended'] ) ) {
1594 $update_data['suspended'] = (int) $input['suspended'];
1595 }
1596
1597 if ( ! empty( $update_data ) ) {
1598 MainWP_DB::instance()->update_website_values( $site->id, $update_data );
1599 if ( isset( $update_data['url'] ) ) {
1600 // A user-set URL that diverges from the child-reported address locks auto-correction.
1601 MainWP_Site_Url_Corrector::after_user_set_url( $site, $update_data['url'] );
1602 }
1603 }
1604
1605 if ( isset( $input['tag_ids'] ) && is_array( $input['tag_ids'] ) ) {
1606 global $wpdb;
1607 // Clear existing tag associations for this site.
1608 //phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery -- for testing results.
1609 $wpdb->query(
1610 $wpdb->prepare(
1611 'DELETE FROM ' . $wpdb->prefix . 'mainwp_wp_group WHERE wpid = %d',
1612 $site->id
1613 )
1614 );
1615 // Add new tag associations.
1616 foreach ( $input['tag_ids'] as $tag_id ) {
1617 MainWP_DB_Common::instance()->update_group_site( (int) $tag_id, $site->id );
1618 }
1619 }
1620
1621 if ( isset( $input['client_id'] ) ) {
1622 MainWP_DB::instance()->update_website_values( $site->id, array( 'client_id' => (int) $input['client_id'] ) );
1623 }
1624
1625 $site = MainWP_DB::instance()->get_website_by_id( $site->id );
1626
1627 return MainWP_Abilities_Util::format_site_for_output( $site, true );
1628 }
1629
1630 /**
1631 * Register mainwp/delete-site-v1 ability.
1632 *
1633 * @return void
1634 */
1635 private static function register_delete_site(): void {
1636 wp_register_ability(
1637 'mainwp/delete-site-v1',
1638 array(
1639 'label' => __( 'Delete MainWP Site', 'mainwp' ),
1640 'description' => __( 'Delete a MainWP child site. Requires confirmation. Supports dry-run mode.', 'mainwp' ),
1641 'category' => 'mainwp-sites',
1642 'input_schema' => self::get_delete_site_input_schema(),
1643 'output_schema' => self::get_delete_site_output_schema(),
1644 'execute_callback' => array( self::class, 'execute_delete_site' ),
1645 'permission_callback' => array( MainWP_Abilities_Util::class, 'check_manage_sites_permission' ),
1646 'meta' => array(
1647 'show_in_rest' => true,
1648 'annotations' => array(
1649 'instructions' => 'Destructive operation - requires confirm:true or dry_run:true. Only call when user explicitly requests deletion.',
1650 'readonly' => false,
1651 'destructive' => true,
1652 'idempotent' => false,
1653 ),
1654 ),
1655 )
1656 );
1657 }
1658
1659 /**
1660 * Get input schema for delete-site-v1.
1661 *
1662 * @return array JSON Schema.
1663 */
1664 private static function get_delete_site_input_schema(): array {
1665 return array(
1666 'type' => 'object',
1667 'required' => array( 'site_id_or_domain' ),
1668 'properties' => array(
1669 'site_id_or_domain' => array(
1670 'type' => array( 'integer', 'string' ),
1671 'description' => __( 'Site ID or domain', 'mainwp' ),
1672 ),
1673 'confirm' => array(
1674 'type' => 'boolean',
1675 'description' => __( 'Must be true to execute deletion', 'mainwp' ),
1676 'default' => false,
1677 ),
1678 'dry_run' => array(
1679 'type' => 'boolean',
1680 'description' => __( 'Preview mode - shows what would be deleted without executing', 'mainwp' ),
1681 'default' => false,
1682 ),
1683 ),
1684 );
1685 }
1686
1687 /**
1688 * Get output schema for delete-site-v1.
1689 *
1690 * @return array JSON Schema.
1691 */
1692 private static function get_delete_site_output_schema(): array {
1693 return array(
1694 'type' => 'object',
1695 'properties' => array(
1696 'dry_run' => array(
1697 'type' => 'boolean',
1698 'description' => __( 'Whether this was a dry-run preview', 'mainwp' ),
1699 ),
1700 'would_affect' => array(
1701 'type' => 'object',
1702 'description' => __( 'Site that would be deleted (dry-run only)', 'mainwp' ),
1703 ),
1704 'warnings' => array(
1705 'type' => 'array',
1706 'description' => __( 'Warnings about deletion impact (dry-run only)', 'mainwp' ),
1707 'items' => array( 'type' => 'string' ),
1708 ),
1709 'deleted' => array(
1710 'type' => 'boolean',
1711 'description' => __( 'Whether deletion was successful', 'mainwp' ),
1712 ),
1713 'site' => array(
1714 'type' => 'object',
1715 'description' => __( 'Deleted site information', 'mainwp' ),
1716 ),
1717 ),
1718 );
1719 }
1720
1721 /**
1722 * Execute delete-site-v1 ability.
1723 *
1724 * @param array $input Input parameters.
1725 * @return array|\WP_Error Result or error.
1726 */
1727 public static function execute_delete_site( $input ) {
1728 $input = MainWP_Abilities_Util::normalize_input( $input );
1729
1730 $dry_run = ! empty( $input['dry_run'] );
1731 $confirm = ! empty( $input['confirm'] );
1732
1733 if ( $dry_run && $confirm ) {
1734 return new \WP_Error(
1735 'mainwp_invalid_input',
1736 __( 'Cannot specify both dry_run and confirm.', 'mainwp' ),
1737 array( 'status' => 400 )
1738 );
1739 }
1740
1741 $site = MainWP_Abilities_Util::resolve_site( $input['site_id_or_domain'] ?? null );
1742 if ( is_wp_error( $site ) ) {
1743 return $site;
1744 }
1745
1746 $access_check = MainWP_Abilities_Util::check_site_access( $site );
1747 if ( is_wp_error( $access_check ) ) {
1748 return $access_check;
1749 }
1750
1751 if ( $dry_run ) {
1752 $warnings = array(
1753 __( 'This action is irreversible. The site record will be permanently deleted from the MainWP Dashboard.', 'mainwp' ),
1754 __( 'All sync data, site options, and historical information stored in the dashboard database will be removed.', 'mainwp' ),
1755 __( 'Group/tag associations for this site will be deleted.', 'mainwp' ),
1756 __( 'The MainWP Child plugin on the remote site will NOT be removed - only the dashboard connection will be deleted.', 'mainwp' ),
1757 );
1758
1759 return array(
1760 'dry_run' => true,
1761 'would_affect' => array(
1762 'id' => (int) $site->id,
1763 'url' => $site->url,
1764 'name' => MainWP_Utility::remove_http_prefix( (string) $site->name, true ),
1765 ),
1766 'warnings' => $warnings,
1767 );
1768 }
1769
1770 if ( ! $confirm ) {
1771 return new \WP_Error(
1772 'mainwp_confirmation_required',
1773 __( 'Deletion requires confirm parameter to be true.', 'mainwp' ),
1774 array( 'status' => 400 )
1775 );
1776 }
1777
1778 $site_info = array(
1779 'id' => (int) $site->id,
1780 'url' => $site->url,
1781 'name' => MainWP_Utility::remove_http_prefix( (string) $site->name, true ),
1782 );
1783
1784 $result = MainWP_DB::instance()->remove_website( $site->id );
1785
1786 if ( false === $result ) {
1787 return new \WP_Error(
1788 'mainwp_deletion_failed',
1789 __( 'Failed to delete site.', 'mainwp' ),
1790 array( 'status' => 500 )
1791 );
1792 }
1793
1794 return array(
1795 'dry_run' => false,
1796 'deleted' => true,
1797 'site' => $site_info,
1798 'would_affect' => array(),
1799 'warnings' => array(),
1800 );
1801 }
1802
1803 /**
1804 * Register mainwp/reconnect-site-v1 ability.
1805 *
1806 * @return void
1807 */
1808 private static function register_reconnect_site(): void {
1809 wp_register_ability(
1810 'mainwp/reconnect-site-v1',
1811 array(
1812 'label' => __( 'Reconnect MainWP Site', 'mainwp' ),
1813 'description' => __( 'Reconnect a disconnected MainWP child site.', 'mainwp' ),
1814 'category' => 'mainwp-sites',
1815 'input_schema' => self::get_single_site_input_schema(),
1816 'output_schema' => self::get_reconnect_site_output_schema(),
1817 'execute_callback' => array( self::class, 'execute_reconnect_site' ),
1818 'permission_callback' => array( MainWP_Abilities_Util::class, 'check_manage_sites_permission' ),
1819 'meta' => array(
1820 'show_in_rest' => true,
1821 'annotations' => array(
1822 'instructions' => '',
1823 'readonly' => false,
1824 'destructive' => false,
1825 'idempotent' => true,
1826 ),
1827 ),
1828 )
1829 );
1830 }
1831
1832 /**
1833 * Get input schema for single-site operations.
1834 *
1835 * @return array JSON Schema.
1836 */
1837 private static function get_single_site_input_schema(): array {
1838 return array(
1839 'type' => 'object',
1840 'required' => array( 'site_id_or_domain' ),
1841 'properties' => array(
1842 'site_id_or_domain' => array(
1843 'type' => array( 'integer', 'string' ),
1844 'description' => __( 'Site ID or domain', 'mainwp' ),
1845 ),
1846 ),
1847 );
1848 }
1849
1850 /**
1851 * Get output schema for reconnect-site-v1.
1852 *
1853 * @return array JSON Schema.
1854 */
1855 private static function get_reconnect_site_output_schema(): array {
1856 return array(
1857 'type' => 'object',
1858 'properties' => array(
1859 'reconnected' => array(
1860 'type' => 'boolean',
1861 'description' => __( 'Whether reconnection was successful', 'mainwp' ),
1862 ),
1863 'site' => array(
1864 'type' => 'object',
1865 'description' => __( 'Site information with status', 'mainwp' ),
1866 ),
1867 ),
1868 );
1869 }
1870
1871 /**
1872 * Execute reconnect-site-v1 ability.
1873 *
1874 * @param array $input Input parameters.
1875 * @return array|\WP_Error Result or error.
1876 */
1877 public static function execute_reconnect_site( $input ) {
1878 $input = MainWP_Abilities_Util::normalize_input( $input );
1879
1880 $site = MainWP_Abilities_Util::resolve_site( $input['site_id_or_domain'] ?? null );
1881 if ( is_wp_error( $site ) ) {
1882 return $site;
1883 }
1884
1885 $access_check = MainWP_Abilities_Util::check_site_access( $site );
1886 if ( is_wp_error( $access_check ) ) {
1887 return $access_check;
1888 }
1889
1890 // Use the View method directly (not AJAX handler which calls die()).
1891 try {
1892 $result = MainWP_Manage_Sites_View::m_reconnect_site( $site );
1893 } catch ( \Exception $e ) {
1894 return new \WP_Error(
1895 'mainwp_reconnect_failed',
1896 $e->getMessage(),
1897 array( 'status' => 503 )
1898 );
1899 }
1900
1901 if ( ! $result ) {
1902 return new \WP_Error(
1903 'mainwp_reconnect_failed',
1904 __( 'Site reconnection failed.', 'mainwp' ),
1905 array( 'status' => 503 )
1906 );
1907 }
1908
1909 $site = MainWP_DB::instance()->get_website_by_id( $site->id );
1910
1911 return array(
1912 'reconnected' => true,
1913 'site' => MainWP_Abilities_Util::format_site_for_output( $site, true ),
1914 );
1915 }
1916
1917 /**
1918 * Register mainwp/disconnect-site-v1 ability.
1919 *
1920 * @return void
1921 */
1922 private static function register_disconnect_site(): void {
1923 wp_register_ability(
1924 'mainwp/disconnect-site-v1',
1925 array(
1926 'label' => __( 'Disconnect MainWP Site', 'mainwp' ),
1927 'description' => __( 'Disconnect a MainWP child site.', 'mainwp' ),
1928 'category' => 'mainwp-sites',
1929 'input_schema' => self::get_single_site_input_schema(),
1930 'output_schema' => self::get_disconnect_site_output_schema(),
1931 'execute_callback' => array( self::class, 'execute_disconnect_site' ),
1932 'permission_callback' => array( MainWP_Abilities_Util::class, 'check_manage_sites_permission' ),
1933 'meta' => array(
1934 'show_in_rest' => true,
1935 'annotations' => array(
1936 'instructions' => '',
1937 'readonly' => false,
1938 'destructive' => false,
1939 'idempotent' => true,
1940 ),
1941 ),
1942 )
1943 );
1944 }
1945
1946 /**
1947 * Get output schema for disconnect-site-v1.
1948 *
1949 * @return array JSON Schema.
1950 */
1951 private static function get_disconnect_site_output_schema(): array {
1952 return array(
1953 'type' => 'object',
1954 'properties' => array(
1955 'disconnected' => array(
1956 'type' => 'boolean',
1957 'description' => __( 'Whether disconnection was successful', 'mainwp' ),
1958 ),
1959 'site' => array(
1960 'type' => 'object',
1961 'description' => __( 'Site information', 'mainwp' ),
1962 ),
1963 ),
1964 );
1965 }
1966
1967 /**
1968 * Execute disconnect-site-v1 ability.
1969 *
1970 * @param array $input Input parameters.
1971 * @return array|\WP_Error Result or error.
1972 */
1973 public static function execute_disconnect_site( $input ) {
1974 $input = MainWP_Abilities_Util::normalize_input( $input );
1975
1976 $site = MainWP_Abilities_Util::resolve_site( $input['site_id_or_domain'] ?? null );
1977 if ( is_wp_error( $site ) ) {
1978 return $site;
1979 }
1980
1981 $access_check = MainWP_Abilities_Util::check_site_access( $site );
1982 if ( is_wp_error( $access_check ) ) {
1983 return $access_check;
1984 }
1985
1986 MainWP_DB::instance()->update_website_sync_values( $site->id, array( 'sync_errors' => __( 'Manually disconnected', 'mainwp' ) ) );
1987
1988 $site = MainWP_DB::instance()->get_website_by_id( $site->id );
1989
1990 return array(
1991 'disconnected' => true,
1992 'site' => MainWP_Abilities_Util::format_site_for_output( $site, true ),
1993 );
1994 }
1995
1996 /**
1997 * Register mainwp/suspend-site-v1 ability.
1998 *
1999 * @return void
2000 */
2001 private static function register_suspend_site(): void {
2002 wp_register_ability(
2003 'mainwp/suspend-site-v1',
2004 array(
2005 'label' => __( 'Suspend MainWP Site', 'mainwp' ),
2006 'description' => __( 'Suspend a MainWP child site.', 'mainwp' ),
2007 'category' => 'mainwp-sites',
2008 'input_schema' => self::get_single_site_input_schema(),
2009 'output_schema' => self::get_suspend_site_output_schema(),
2010 'execute_callback' => array( self::class, 'execute_suspend_site' ),
2011 'permission_callback' => array( MainWP_Abilities_Util::class, 'check_manage_sites_permission' ),
2012 'meta' => array(
2013 'show_in_rest' => true,
2014 'annotations' => array(
2015 'instructions' => '',
2016 'readonly' => false,
2017 'destructive' => false,
2018 'idempotent' => true,
2019 ),
2020 ),
2021 )
2022 );
2023 }
2024
2025 /**
2026 * Get output schema for suspend-site-v1.
2027 *
2028 * @return array JSON Schema.
2029 */
2030 private static function get_suspend_site_output_schema(): array {
2031 return array(
2032 'type' => 'object',
2033 'properties' => array(
2034 'suspended' => array(
2035 'type' => 'boolean',
2036 'description' => __( 'Whether suspension was successful', 'mainwp' ),
2037 ),
2038 'site' => array(
2039 'type' => 'object',
2040 'description' => __( 'Site information with updated suspended status', 'mainwp' ),
2041 ),
2042 ),
2043 );
2044 }
2045
2046 /**
2047 * Execute suspend-site-v1 ability.
2048 *
2049 * @param array $input Input parameters.
2050 * @return array|\WP_Error Result or error.
2051 */
2052 public static function execute_suspend_site( $input ) {
2053 $input = MainWP_Abilities_Util::normalize_input( $input );
2054
2055 $site = MainWP_Abilities_Util::resolve_site( $input['site_id_or_domain'] ?? null );
2056 if ( is_wp_error( $site ) ) {
2057 return $site;
2058 }
2059
2060 $access_check = MainWP_Abilities_Util::check_site_access( $site );
2061 if ( is_wp_error( $access_check ) ) {
2062 return $access_check;
2063 }
2064
2065 MainWP_DB::instance()->update_website_values( $site->id, array( 'suspended' => 1 ) );
2066
2067 do_action( 'mainwp_site_suspended', $site, 1 );
2068
2069 $site = MainWP_DB::instance()->get_website_by_id( $site->id );
2070
2071 return array(
2072 'suspended' => true,
2073 'site' => MainWP_Abilities_Util::format_site_for_output( $site, true ),
2074 );
2075 }
2076
2077 /**
2078 * Register mainwp/unsuspend-site-v1 ability.
2079 *
2080 * @return void
2081 */
2082 private static function register_unsuspend_site(): void {
2083 wp_register_ability(
2084 'mainwp/unsuspend-site-v1',
2085 array(
2086 'label' => __( 'Unsuspend MainWP Site', 'mainwp' ),
2087 'description' => __( 'Unsuspend a MainWP child site.', 'mainwp' ),
2088 'category' => 'mainwp-sites',
2089 'input_schema' => self::get_single_site_input_schema(),
2090 'output_schema' => self::get_unsuspend_site_output_schema(),
2091 'execute_callback' => array( self::class, 'execute_unsuspend_site' ),
2092 'permission_callback' => array( MainWP_Abilities_Util::class, 'check_manage_sites_permission' ),
2093 'meta' => array(
2094 'show_in_rest' => true,
2095 'annotations' => array(
2096 'instructions' => '',
2097 'readonly' => false,
2098 'destructive' => false,
2099 'idempotent' => true,
2100 ),
2101 ),
2102 )
2103 );
2104 }
2105
2106 /**
2107 * Get output schema for unsuspend-site-v1.
2108 *
2109 * @return array JSON Schema.
2110 */
2111 private static function get_unsuspend_site_output_schema(): array {
2112 return array(
2113 'type' => 'object',
2114 'properties' => array(
2115 'unsuspended' => array(
2116 'type' => 'boolean',
2117 'description' => __( 'Whether unsuspension was successful', 'mainwp' ),
2118 ),
2119 'site' => array(
2120 'type' => 'object',
2121 'description' => __( 'Site information with updated suspended status', 'mainwp' ),
2122 ),
2123 ),
2124 );
2125 }
2126
2127 /**
2128 * Execute unsuspend-site-v1 ability.
2129 *
2130 * @param array $input Input parameters.
2131 * @return array|\WP_Error Result or error.
2132 */
2133 public static function execute_unsuspend_site( $input ) {
2134 $input = MainWP_Abilities_Util::normalize_input( $input );
2135
2136 $site = MainWP_Abilities_Util::resolve_site( $input['site_id_or_domain'] ?? null );
2137 if ( is_wp_error( $site ) ) {
2138 return $site;
2139 }
2140
2141 $access_check = MainWP_Abilities_Util::check_site_access( $site );
2142 if ( is_wp_error( $access_check ) ) {
2143 return $access_check;
2144 }
2145
2146 MainWP_DB::instance()->update_website_values( $site->id, array( 'suspended' => 0 ) );
2147
2148 do_action( 'mainwp_site_suspended', $site, 0 );
2149
2150 $site = MainWP_DB::instance()->get_website_by_id( $site->id );
2151
2152 return array(
2153 'unsuspended' => true,
2154 'site' => MainWP_Abilities_Util::format_site_for_output( $site, true ),
2155 );
2156 }
2157
2158 /**
2159 * Register mainwp/check-site-v1 ability.
2160 *
2161 * @return void
2162 */
2163 private static function register_check_site(): void {
2164 wp_register_ability(
2165 'mainwp/check-site-v1',
2166 array(
2167 'label' => __( 'Check MainWP Site', 'mainwp' ),
2168 'description' => __( 'Check connectivity status of a MainWP child site.', 'mainwp' ),
2169 'category' => 'mainwp-sites',
2170 'input_schema' => self::get_single_site_input_schema(),
2171 'output_schema' => self::get_check_site_output_schema(),
2172 'execute_callback' => array( self::class, 'execute_check_site' ),
2173 'permission_callback' => array( MainWP_Abilities_Util::class, 'check_rest_api_permission' ),
2174 'meta' => array(
2175 'show_in_rest' => true,
2176 'annotations' => array(
2177 'instructions' => '',
2178 'readonly' => false,
2179 'destructive' => false,
2180 'idempotent' => true,
2181 ),
2182 ),
2183 )
2184 );
2185 }
2186
2187 /**
2188 * Get output schema for check-site-v1.
2189 *
2190 * @return array JSON Schema.
2191 */
2192 private static function get_check_site_output_schema(): array {
2193 return array(
2194 'type' => 'object',
2195 'properties' => array(
2196 'site_id' => array(
2197 'type' => 'integer',
2198 'description' => __( 'Site ID', 'mainwp' ),
2199 ),
2200 'response_time' => array(
2201 'type' => 'number',
2202 'description' => __( 'Response time in seconds', 'mainwp' ),
2203 ),
2204 'checked' => array(
2205 'type' => 'boolean',
2206 'description' => __( 'Whether check was completed', 'mainwp' ),
2207 ),
2208 'site' => array(
2209 'type' => 'object',
2210 'description' => __( 'Site information', 'mainwp' ),
2211 ),
2212 'status' => array(
2213 'type' => 'object',
2214 'description' => __( 'Connectivity status details', 'mainwp' ),
2215 'properties' => array(
2216 'online' => array( 'type' => 'boolean' ),
2217 'http_code' => array( 'type' => 'integer' ),
2218 'response_time' => array( 'type' => 'number' ),
2219 ),
2220 ),
2221 ),
2222 );
2223 }
2224
2225 /**
2226 * Execute check-site-v1 ability.
2227 *
2228 * @param array $input Input parameters.
2229 * @return array|\WP_Error Result or error.
2230 */
2231 public static function execute_check_site( $input ) {
2232 $input = MainWP_Abilities_Util::normalize_input( $input );
2233
2234 $site = MainWP_Abilities_Util::resolve_site( $input['site_id_or_domain'] ?? null );
2235 if ( is_wp_error( $site ) ) {
2236 return $site;
2237 }
2238
2239 $access_check = MainWP_Abilities_Util::check_site_access( $site );
2240 if ( is_wp_error( $access_check ) ) {
2241 return $access_check;
2242 }
2243
2244 $start_time = microtime( true );
2245 $result = MainWP_Monitoring_Handler::handle_check_website( $site );
2246 $end_time = microtime( true );
2247
2248 if ( is_wp_error( $result ) ) {
2249 return new \WP_Error(
2250 'mainwp_check_failed',
2251 $result->get_error_message(),
2252 array( 'status' => 503 )
2253 );
2254 }
2255
2256 if ( ! is_array( $result ) ) {
2257 return new \WP_Error(
2258 'mainwp_check_failed',
2259 __( 'Unable to check site status.', 'mainwp' ),
2260 array( 'status' => 503 )
2261 );
2262 }
2263
2264 $http_code = isset( $result['httpCode'] ) ? (int) $result['httpCode'] : 0;
2265
2266 // Determine online status from uptime monitoring or legacy path.
2267 // Uptime monitoring: 0=DOWN, 1=UP, 2=PENDING.
2268 if ( isset( $result['new_uptime_status'] ) ) {
2269 $online = ( 1 === (int) $result['new_uptime_status'] );
2270 } else {
2271 // Legacy try_visit path — check HTTP code against ignored-codes list.
2272 $online = MainWP_Connect::check_ignored_http_code( $http_code, $site );
2273 }
2274
2275 $response_time = round( $end_time - $start_time, 2 );
2276
2277 return array(
2278 'site_id' => (int) $site->id,
2279 'response_time' => $response_time,
2280 'checked' => true,
2281 'site' => array(
2282 'id' => (int) $site->id,
2283 'url' => $site->url,
2284 'name' => MainWP_Utility::remove_http_prefix( (string) $site->name, true ),
2285 ),
2286 'status' => array(
2287 'online' => (bool) $online,
2288 'http_code' => $http_code,
2289 'response_time' => $response_time,
2290 ),
2291 );
2292 }
2293
2294 // =========================================================================
2295 // Plugin Management Abilities (4)
2296 // =========================================================================
2297
2298 /**
2299 * Register mainwp/activate-site-plugins-v1 ability.
2300 *
2301 * @return void
2302 */
2303 private static function register_activate_site_plugins(): void {
2304 wp_register_ability(
2305 'mainwp/activate-site-plugins-v1',
2306 array(
2307 'label' => __( 'Activate Site Plugins', 'mainwp' ),
2308 'description' => __( 'Activate plugins on a MainWP child site.', 'mainwp' ),
2309 'category' => 'mainwp-sites',
2310 'input_schema' => self::get_activate_site_plugins_input_schema(),
2311 'output_schema' => self::get_activate_plugins_output_schema(),
2312 'execute_callback' => array( self::class, 'execute_activate_site_plugins' ),
2313 'permission_callback' => array( MainWP_Abilities_Util::class, 'check_manage_sites_permission' ),
2314 'meta' => array(
2315 'show_in_rest' => true,
2316 'annotations' => array(
2317 'instructions' => '',
2318 'readonly' => false,
2319 'destructive' => false,
2320 'idempotent' => true,
2321 ),
2322 ),
2323 )
2324 );
2325 }
2326
2327 /**
2328 * Get input schema for activate-site-plugins-v1.
2329 *
2330 * @return array JSON Schema.
2331 */
2332 private static function get_activate_site_plugins_input_schema(): array {
2333 return array(
2334 'type' => 'object',
2335 'required' => array( 'site_id_or_domain', 'plugins' ),
2336 'properties' => array(
2337 'site_id_or_domain' => array(
2338 'type' => array( 'integer', 'string' ),
2339 'description' => __( 'Site ID or domain', 'mainwp' ),
2340 ),
2341 'plugins' => array(
2342 'type' => 'array',
2343 'description' => __( 'Plugin slugs to activate (e.g., akismet/akismet.php)', 'mainwp' ),
2344 'items' => array( 'type' => 'string' ),
2345 'minItems' => 1,
2346 ),
2347 ),
2348 );
2349 }
2350
2351 /**
2352 * Get output schema for activate-site-plugins-v1.
2353 *
2354 * @return array JSON Schema.
2355 */
2356 private static function get_activate_plugins_output_schema(): array {
2357 return array(
2358 'type' => 'object',
2359 'properties' => array(
2360 'activated' => array(
2361 'type' => 'array',
2362 'description' => __( 'Successfully activated plugins', 'mainwp' ),
2363 'items' => array( 'type' => 'object' ),
2364 ),
2365 'errors' => array(
2366 'type' => 'array',
2367 'description' => __( 'Errors encountered', 'mainwp' ),
2368 'items' => array(
2369 'type' => 'object',
2370 'properties' => array(
2371 'slug' => array( 'type' => 'string' ),
2372 'code' => array( 'type' => 'string' ),
2373 'message' => array( 'type' => 'string' ),
2374 ),
2375 ),
2376 ),
2377 ),
2378 );
2379 }
2380
2381 /**
2382 * Get output schema for deactivate-site-plugins-v1.
2383 *
2384 * @return array JSON Schema.
2385 */
2386 private static function get_deactivate_plugins_output_schema(): array {
2387 return array(
2388 'type' => 'object',
2389 'properties' => array(
2390 'deactivated' => array(
2391 'type' => 'array',
2392 'description' => __( 'Successfully deactivated plugins', 'mainwp' ),
2393 'items' => array( 'type' => 'object' ),
2394 ),
2395 'errors' => array(
2396 'type' => 'array',
2397 'description' => __( 'Errors encountered', 'mainwp' ),
2398 'items' => array(
2399 'type' => 'object',
2400 'properties' => array(
2401 'slug' => array( 'type' => 'string' ),
2402 'code' => array( 'type' => 'string' ),
2403 'message' => array( 'type' => 'string' ),
2404 ),
2405 ),
2406 ),
2407 ),
2408 );
2409 }
2410
2411 /**
2412 * Execute activate-site-plugins-v1 ability.
2413 *
2414 * @param array $input Input parameters.
2415 * @return array|\WP_Error Result or error.
2416 */
2417 public static function execute_activate_site_plugins( $input ) {
2418 $input = MainWP_Abilities_Util::normalize_input( $input );
2419
2420 $site = MainWP_Abilities_Util::resolve_site( $input['site_id_or_domain'] ?? null );
2421 if ( is_wp_error( $site ) ) {
2422 return $site;
2423 }
2424
2425 $access_check = MainWP_Abilities_Util::check_site_access( $site );
2426 if ( is_wp_error( $access_check ) ) {
2427 return $access_check;
2428 }
2429
2430 // Check child version for remote plugin operations.
2431 $version_check = MainWP_Abilities_Util::check_child_version( $site );
2432 if ( is_wp_error( $version_check ) ) {
2433 return $version_check;
2434 }
2435
2436 $plugins = isset( $input['plugins'] ) && is_array( $input['plugins'] ) ? $input['plugins'] : array();
2437 if ( empty( $plugins ) ) {
2438 return new \WP_Error(
2439 'mainwp_invalid_input',
2440 __( 'No plugins specified.', 'mainwp' ),
2441 array( 'status' => 400 )
2442 );
2443 }
2444
2445 $activated = array();
2446 $errors = array();
2447
2448 foreach ( $plugins as $plugin_slug ) {
2449 $result = MainWP_Connect::fetch_url_authed(
2450 $site,
2451 'plugin_action',
2452 array(
2453 'action' => 'activate',
2454 'plugin' => $plugin_slug,
2455 )
2456 );
2457
2458 if ( MainWP_Abilities_Util::is_child_response_success( $result ) ) {
2459 $activated[] = MainWP_Abilities_Util::format_plugin_for_output(
2460 array(
2461 'slug' => $plugin_slug,
2462 'Name' => $plugin_slug,
2463 'active' => true, // Plugin was just activated.
2464 )
2465 );
2466 } else {
2467 $message = is_array( $result ) && isset( $result['error'] ) ? $result['error'] : __( 'Activation failed', 'mainwp' );
2468 $errors[] = array(
2469 'slug' => $plugin_slug,
2470 'code' => 'mainwp_activation_failed',
2471 'message' => $message,
2472 );
2473 }
2474 }
2475
2476 return array(
2477 'activated' => $activated,
2478 'errors' => $errors,
2479 );
2480 }
2481
2482 /**
2483 * Register mainwp/deactivate-site-plugins-v1 ability.
2484 *
2485 * @return void
2486 */
2487 private static function register_deactivate_site_plugins(): void {
2488 wp_register_ability(
2489 'mainwp/deactivate-site-plugins-v1',
2490 array(
2491 'label' => __( 'Deactivate Site Plugins', 'mainwp' ),
2492 'description' => __( 'Deactivate plugins on a MainWP child site.', 'mainwp' ),
2493 'category' => 'mainwp-sites',
2494 'input_schema' => self::get_deactivate_site_plugins_input_schema(),
2495 'output_schema' => self::get_deactivate_plugins_output_schema(),
2496 'execute_callback' => array( self::class, 'execute_deactivate_site_plugins' ),
2497 'permission_callback' => array( MainWP_Abilities_Util::class, 'check_manage_sites_permission' ),
2498 'meta' => array(
2499 'show_in_rest' => true,
2500 'annotations' => array(
2501 'instructions' => '',
2502 'readonly' => false,
2503 'destructive' => false,
2504 'idempotent' => true,
2505 ),
2506 ),
2507 )
2508 );
2509 }
2510
2511 /**
2512 * Get input schema for deactivate-site-plugins-v1.
2513 *
2514 * @return array JSON Schema.
2515 */
2516 private static function get_deactivate_site_plugins_input_schema(): array {
2517 return array(
2518 'type' => 'object',
2519 'required' => array( 'site_id_or_domain', 'plugins' ),
2520 'properties' => array(
2521 'site_id_or_domain' => array(
2522 'type' => array( 'integer', 'string' ),
2523 'description' => __( 'Site ID or domain', 'mainwp' ),
2524 ),
2525 'plugins' => array(
2526 'type' => 'array',
2527 'description' => __( 'Plugin slugs to deactivate', 'mainwp' ),
2528 'items' => array( 'type' => 'string' ),
2529 'minItems' => 1,
2530 ),
2531 ),
2532 );
2533 }
2534
2535 /**
2536 * Execute deactivate-site-plugins-v1 ability.
2537 *
2538 * @param array $input Input parameters.
2539 * @return array|\WP_Error Result or error.
2540 */
2541 public static function execute_deactivate_site_plugins( $input ) {
2542 $input = MainWP_Abilities_Util::normalize_input( $input );
2543
2544 $site = MainWP_Abilities_Util::resolve_site( $input['site_id_or_domain'] ?? null );
2545 if ( is_wp_error( $site ) ) {
2546 return $site;
2547 }
2548
2549 $access_check = MainWP_Abilities_Util::check_site_access( $site );
2550 if ( is_wp_error( $access_check ) ) {
2551 return $access_check;
2552 }
2553
2554 // Check child version for remote plugin operations.
2555 $version_check = MainWP_Abilities_Util::check_child_version( $site );
2556 if ( is_wp_error( $version_check ) ) {
2557 return $version_check;
2558 }
2559
2560 $plugins = isset( $input['plugins'] ) && is_array( $input['plugins'] ) ? $input['plugins'] : array();
2561 if ( empty( $plugins ) ) {
2562 return new \WP_Error(
2563 'mainwp_invalid_input',
2564 __( 'No plugins specified.', 'mainwp' ),
2565 array( 'status' => 400 )
2566 );
2567 }
2568
2569 $deactivated = array();
2570 $errors = array();
2571
2572 foreach ( $plugins as $plugin_slug ) {
2573 $result = MainWP_Connect::fetch_url_authed(
2574 $site,
2575 'plugin_action',
2576 array(
2577 'action' => 'deactivate',
2578 'plugin' => $plugin_slug,
2579 )
2580 );
2581
2582 if ( MainWP_Abilities_Util::is_child_response_success( $result ) ) {
2583 $deactivated[] = MainWP_Abilities_Util::format_plugin_for_output(
2584 array(
2585 'slug' => $plugin_slug,
2586 'Name' => $plugin_slug,
2587 'active' => false, // Plugin was just deactivated.
2588 )
2589 );
2590 } else {
2591 $message = is_array( $result ) && isset( $result['error'] ) ? $result['error'] : __( 'Deactivation failed', 'mainwp' );
2592 $errors[] = array(
2593 'slug' => $plugin_slug,
2594 'code' => 'mainwp_deactivation_failed',
2595 'message' => $message,
2596 );
2597 }
2598 }
2599
2600 return array(
2601 'deactivated' => $deactivated,
2602 'errors' => $errors,
2603 );
2604 }
2605
2606 /**
2607 * Register mainwp/delete-site-plugins-v1 ability.
2608 *
2609 * @return void
2610 */
2611 private static function register_delete_site_plugins(): void {
2612 wp_register_ability(
2613 'mainwp/delete-site-plugins-v1',
2614 array(
2615 'label' => __( 'Delete Site Plugins', 'mainwp' ),
2616 'description' => __( 'Delete plugins from a MainWP child site. Requires confirmation. Supports dry-run mode.', 'mainwp' ),
2617 'category' => 'mainwp-sites',
2618 'input_schema' => self::get_delete_site_plugins_input_schema(),
2619 'output_schema' => self::get_delete_plugins_output_schema(),
2620 'execute_callback' => array( self::class, 'execute_delete_site_plugins' ),
2621 'permission_callback' => array( MainWP_Abilities_Util::class, 'check_manage_sites_permission' ),
2622 'meta' => array(
2623 'show_in_rest' => true,
2624 'annotations' => array(
2625 'instructions' => 'Destructive operation - requires confirm:true or dry_run:true. Only call when user explicitly requests deletion. Permanently removes plugins from the child site.',
2626 'readonly' => false,
2627 'destructive' => true,
2628 'idempotent' => false,
2629 ),
2630 ),
2631 )
2632 );
2633 }
2634
2635 /**
2636 * Get input schema for delete-site-plugins-v1.
2637 *
2638 * @return array JSON Schema.
2639 */
2640 private static function get_delete_site_plugins_input_schema(): array {
2641 return array(
2642 'type' => 'object',
2643 'required' => array( 'site_id_or_domain', 'plugins' ),
2644 'properties' => array(
2645 'site_id_or_domain' => array(
2646 'type' => array( 'integer', 'string' ),
2647 'description' => __( 'Site ID or domain', 'mainwp' ),
2648 ),
2649 'plugins' => array(
2650 'type' => 'array',
2651 'description' => __( 'Plugin slugs to delete', 'mainwp' ),
2652 'items' => array( 'type' => 'string' ),
2653 'minItems' => 1,
2654 ),
2655 'confirm' => array(
2656 'type' => 'boolean',
2657 'description' => __( 'Must be true to execute deletion', 'mainwp' ),
2658 'default' => false,
2659 ),
2660 'dry_run' => array(
2661 'type' => 'boolean',
2662 'description' => __( 'Preview mode', 'mainwp' ),
2663 'default' => false,
2664 ),
2665 ),
2666 );
2667 }
2668
2669 /**
2670 * Get output schema for delete-site-plugins-v1.
2671 *
2672 * @return array JSON Schema.
2673 */
2674 private static function get_delete_plugins_output_schema(): array {
2675 return array(
2676 'type' => 'object',
2677 'properties' => array(
2678 'dry_run' => array( 'type' => 'boolean' ),
2679 'would_affect' => array(
2680 'type' => 'array',
2681 'items' => array( 'type' => 'object' ),
2682 ),
2683 'count' => array( 'type' => 'integer' ),
2684 'warnings' => array(
2685 'type' => 'array',
2686 'items' => array( 'type' => 'string' ),
2687 ),
2688 'deleted' => array(
2689 'type' => 'array',
2690 'items' => array( 'type' => 'object' ),
2691 ),
2692 'errors' => array(
2693 'type' => 'array',
2694 'items' => array( 'type' => 'object' ),
2695 ),
2696 ),
2697 );
2698 }
2699
2700 /**
2701 * Generic helper to delete remote items (plugins or themes) from a child site.
2702 *
2703 * This method implements the shared logic for plugin and theme deletion:
2704 * - Input validation (dry_run/confirm mutual exclusivity)
2705 * - Site resolution and access checks
2706 * - Dry-run mode with would_affect/warnings
2707 * - Confirmed deletion with remote action calls
2708 * - Consistent response shaping
2709 *
2710 * @param array $input Raw input parameters.
2711 * @param array $adapter Configuration for item type with keys:
2712 * - 'input_key' (string) Key in input array ('plugins' or 'themes').
2713 * - 'site_property' (string) Site object property ('plugins' or 'themes').
2714 * - 'remote_action' (string) MainWP Connect action ('plugin_action' or 'theme_action').
2715 * - 'remote_param' (string) Remote action param key ('plugin' or 'theme').
2716 * - 'formatter' (callable) Formatter function for output.
2717 * - 'empty_error' (string) Error message when no items specified.
2718 * - 'active_warning' (string) Warning message format for active items (%s = slug).
2719 * - 'active_singular' (bool) True if only one item can be active (themes).
2720 * @return array|\WP_Error Result or error.
2721 */
2722 private static function execute_delete_remote_items( array $input, array $adapter ) {
2723 $input = MainWP_Abilities_Util::normalize_input( $input );
2724
2725 $dry_run = ! empty( $input['dry_run'] );
2726 $confirm = ! empty( $input['confirm'] );
2727
2728 if ( $dry_run && $confirm ) {
2729 return new \WP_Error(
2730 'mainwp_invalid_input',
2731 __( 'Cannot specify both dry_run and confirm.', 'mainwp' ),
2732 array( 'status' => 400 )
2733 );
2734 }
2735
2736 $site = MainWP_Abilities_Util::resolve_site( $input['site_id_or_domain'] ?? null );
2737 if ( is_wp_error( $site ) ) {
2738 return $site;
2739 }
2740
2741 $access_check = MainWP_Abilities_Util::check_site_access( $site );
2742 if ( is_wp_error( $access_check ) ) {
2743 return $access_check;
2744 }
2745
2746 $version_check = MainWP_Abilities_Util::check_child_version( $site );
2747 if ( is_wp_error( $version_check ) ) {
2748 return $version_check;
2749 }
2750
2751 $items = isset( $input[ $adapter['input_key'] ] ) && is_array( $input[ $adapter['input_key'] ] )
2752 ? $input[ $adapter['input_key'] ]
2753 : array();
2754
2755 if ( empty( $items ) ) {
2756 return new \WP_Error(
2757 'mainwp_invalid_input',
2758 $adapter['empty_error'],
2759 array( 'status' => 400 )
2760 );
2761 }
2762
2763 if ( $dry_run ) {
2764 return self::build_delete_dry_run_response( $site, $items, $adapter );
2765 }
2766
2767 if ( ! $confirm ) {
2768 return new \WP_Error(
2769 'mainwp_confirmation_required',
2770 __( 'Deletion requires confirm parameter to be true.', 'mainwp' ),
2771 array( 'status' => 400 )
2772 );
2773 }
2774
2775 return self::execute_delete_remote_calls( $site, $items, $adapter );
2776 }
2777
2778 /**
2779 * Build dry-run response for delete operations.
2780 *
2781 * @param object $site Site object.
2782 * @param array $items Items to delete.
2783 * @param array $adapter Item type adapter configuration.
2784 * @return array Dry-run response.
2785 */
2786 private static function build_delete_dry_run_response( $site, array $items, array $adapter ): array { // phpcs:ignore -- NOSONAR - complexity.
2787 $would_affect = array();
2788 $warnings = array();
2789
2790 // Get installed items from site data.
2791 $site_property = $adapter['site_property'];
2792 $installed_items = ! empty( $site->$site_property ) ? json_decode( $site->$site_property, true ) : array();
2793 if ( ! is_array( $installed_items ) ) {
2794 $installed_items = array();
2795 }
2796
2797 // Build lookup of active items.
2798 $active_items = array();
2799 $active_singular = ! empty( $adapter['active_singular'] );
2800 $single_active_slug = '';
2801
2802 foreach ( $installed_items as $key => $item_data ) {
2803 $slug = is_string( $key ) && ! empty( $key ) ? $key : ( $item_data['slug'] ?? '' );
2804 if ( ! empty( $slug ) && ! empty( $item_data['active'] ) ) {
2805 if ( $active_singular ) {
2806 $single_active_slug = $slug;
2807 break;
2808 }
2809 $active_items[ $slug ] = true;
2810 }
2811 }
2812
2813 $formatter = $adapter['formatter'];
2814 foreach ( $items as $slug ) {
2815 $would_affect[] = call_user_func(
2816 $formatter,
2817 array(
2818 'slug' => $slug,
2819 'Name' => $slug,
2820 )
2821 );
2822
2823 // Check if item is active and add warning.
2824 $is_active = $active_singular
2825 ? ( $slug === $single_active_slug )
2826 : isset( $active_items[ $slug ] );
2827
2828 if ( $is_active ) {
2829 $warnings[] = sprintf( $adapter['active_warning'], $slug );
2830 }
2831 }
2832
2833 return array(
2834 'dry_run' => true,
2835 'would_affect' => $would_affect,
2836 'count' => count( $would_affect ),
2837 'warnings' => $warnings,
2838 );
2839 }
2840
2841 /**
2842 * Execute remote delete calls for items.
2843 *
2844 * @param object $site Site object.
2845 * @param array $items Items to delete.
2846 * @param array $adapter Item type adapter configuration.
2847 * @return array Response with deleted items and errors.
2848 */
2849 private static function execute_delete_remote_calls( $site, array $items, array $adapter ): array {
2850 $deleted = array();
2851 $errors = array();
2852
2853 $remote_action = $adapter['remote_action'];
2854 $remote_param = $adapter['remote_param'];
2855 $formatter = $adapter['formatter'];
2856
2857 foreach ( $items as $slug ) {
2858 $result = MainWP_Connect::fetch_url_authed(
2859 $site,
2860 $remote_action,
2861 array(
2862 'action' => 'delete',
2863 $remote_param => $slug,
2864 )
2865 );
2866
2867 if ( is_wp_error( $result ) ) {
2868 $errors[] = array(
2869 'slug' => $slug,
2870 'code' => 'mainwp_' . $result->get_error_code(),
2871 'message' => $result->get_error_message(),
2872 );
2873 } elseif ( MainWP_Abilities_Util::is_child_response_success( $result ) ) {
2874 $deleted[] = call_user_func(
2875 $formatter,
2876 array(
2877 'slug' => $slug,
2878 'Name' => $slug,
2879 )
2880 );
2881 } else {
2882 $message = is_array( $result ) && isset( $result['error'] ) ? $result['error'] : __( 'Deletion failed', 'mainwp' );
2883 $errors[] = array(
2884 'slug' => $slug,
2885 'code' => 'mainwp_deletion_failed',
2886 'message' => $message,
2887 );
2888 }
2889 }
2890
2891 return array(
2892 'dry_run' => false,
2893 'deleted' => $deleted,
2894 'count' => count( $deleted ),
2895 'errors' => $errors,
2896 'warnings' => array(),
2897 );
2898 }
2899
2900 /**
2901 * Execute delete-site-plugins-v1 ability.
2902 *
2903 * @param array $input Input parameters.
2904 * @return array|\WP_Error Result or error.
2905 */
2906 public static function execute_delete_site_plugins( $input ) {
2907 return self::execute_delete_remote_items(
2908 $input,
2909 array(
2910 'input_key' => 'plugins',
2911 'site_property' => 'plugins',
2912 'remote_action' => 'plugin_action',
2913 'remote_param' => 'plugin',
2914 'formatter' => array( MainWP_Abilities_Util::class, 'format_plugin_for_output' ),
2915 'empty_error' => __( 'No plugins specified.', 'mainwp' ),
2916 /* translators: %s: plugin name */
2917 'active_warning' => __( 'Plugin %s is currently active.', 'mainwp' ),
2918 'active_singular' => false,
2919 )
2920 );
2921 }
2922
2923 /**
2924 * Register mainwp/get-abandoned-plugins-v1 ability.
2925 *
2926 * @return void
2927 */
2928 private static function register_get_abandoned_plugins(): void {
2929 wp_register_ability(
2930 'mainwp/get-abandoned-plugins-v1',
2931 array(
2932 'label' => __( 'Get Abandoned Plugins', 'mainwp' ),
2933 'description' => __( 'Get list of abandoned plugins on a child site. Returns plugins detected during last sync. Data freshness depends on sync frequency.', 'mainwp' ),
2934 'category' => 'mainwp-sites',
2935 'input_schema' => self::get_single_site_input_schema(),
2936 'output_schema' => self::get_abandoned_plugins_output_schema(),
2937 'execute_callback' => array( self::class, 'execute_get_abandoned_plugins' ),
2938 'permission_callback' => MainWP_Abilities_Util::make_site_permission_callback( 'site_id_or_domain' ),
2939 'meta' => array(
2940 'show_in_rest' => true,
2941 'annotations' => array(
2942 'instructions' => '',
2943 'readonly' => true,
2944 'destructive' => false,
2945 'idempotent' => true,
2946 ),
2947 ),
2948 )
2949 );
2950 }
2951
2952 /**
2953 * Get output schema for get-abandoned-plugins-v1.
2954 *
2955 * @return array JSON Schema.
2956 */
2957 private static function get_abandoned_plugins_output_schema(): array {
2958 return array(
2959 'type' => 'object',
2960 'properties' => array(
2961 'plugins' => array(
2962 'type' => 'array',
2963 'items' => array(
2964 'type' => 'object',
2965 'properties' => array(
2966 'slug' => array( 'type' => 'string' ),
2967 'name' => array( 'type' => 'string' ),
2968 'version' => array( 'type' => 'string' ),
2969 'last_updated' => array( 'type' => 'string' ),
2970 'days_since_update' => array( 'type' => array( 'integer', 'null' ) ),
2971 ),
2972 ),
2973 ),
2974 'total' => array( 'type' => 'integer' ),
2975 ),
2976 );
2977 }
2978
2979 /**
2980 * Execute get-abandoned-plugins-v1 ability.
2981 *
2982 * @param array $input Input parameters.
2983 * @return array|\WP_Error Result or error.
2984 */
2985 public static function execute_get_abandoned_plugins( $input ) { // phpcs:ignore -- NOSONAR - complexity.
2986 $input = MainWP_Abilities_Util::normalize_input( $input );
2987
2988 $site = MainWP_Abilities_Util::resolve_site( $input['site_id_or_domain'] ?? null );
2989 if ( is_wp_error( $site ) ) {
2990 return $site;
2991 }
2992
2993 $access_check = MainWP_Abilities_Util::check_site_access( $site );
2994 if ( is_wp_error( $access_check ) ) {
2995 return $access_check;
2996 }
2997
2998 $abandoned_data = MainWP_DB::instance()->get_website_option( $site, 'plugins_outdate_info' );
2999 $abandoned = ! empty( $abandoned_data ) ? json_decode( $abandoned_data, true ) : array();
3000
3001 if ( ! is_array( $abandoned ) ) {
3002 $abandoned = array();
3003 }
3004
3005 $plugins = array();
3006 foreach ( $abandoned as $slug => $info ) {
3007 // Convert timestamp to ISO 8601 date string if numeric.
3008 $last_updated = isset( $info['last_updated'] ) ? $info['last_updated'] : '';
3009 if ( is_numeric( $last_updated ) ) {
3010 $last_updated = gmdate( 'c', (int) $last_updated );
3011 }
3012
3013 // Calculate days since update only if we have a valid numeric timestamp.
3014 $days_since_update = null;
3015 if ( isset( $info['outdate_timestamp'] ) && is_numeric( $info['outdate_timestamp'] ) && $info['outdate_timestamp'] > 0 ) {
3016 $days_since_update = (int) ( ( time() - $info['outdate_timestamp'] ) / DAY_IN_SECONDS );
3017 }
3018
3019 $plugins[] = array(
3020 'slug' => $slug,
3021 'name' => isset( $info['Name'] ) ? $info['Name'] : $slug,
3022 'version' => isset( $info['Version'] ) ? $info['Version'] : '',
3023 'last_updated' => (string) $last_updated,
3024 'days_since_update' => $days_since_update,
3025 );
3026 }
3027
3028 return array(
3029 'plugins' => $plugins,
3030 'total' => count( $plugins ),
3031 );
3032 }
3033
3034 // =========================================================================
3035 // Theme Management Abilities (3)
3036 // =========================================================================
3037
3038 /**
3039 * Register mainwp/activate-site-theme-v1 ability.
3040 *
3041 * @return void
3042 */
3043 private static function register_activate_site_theme(): void {
3044 wp_register_ability(
3045 'mainwp/activate-site-theme-v1',
3046 array(
3047 'label' => __( 'Activate Site Theme', 'mainwp' ),
3048 'description' => __( 'Activate a theme on a MainWP child site.', 'mainwp' ),
3049 'category' => 'mainwp-sites',
3050 'input_schema' => self::get_activate_site_theme_input_schema(),
3051 'output_schema' => self::get_activate_theme_output_schema(),
3052 'execute_callback' => array( self::class, 'execute_activate_site_theme' ),
3053 'permission_callback' => array( MainWP_Abilities_Util::class, 'check_manage_sites_permission' ),
3054 'meta' => array(
3055 'show_in_rest' => true,
3056 'annotations' => array(
3057 'instructions' => '',
3058 'readonly' => false,
3059 'destructive' => false,
3060 'idempotent' => true,
3061 ),
3062 ),
3063 )
3064 );
3065 }
3066
3067 /**
3068 * Get input schema for activate-site-theme-v1.
3069 *
3070 * @return array JSON Schema.
3071 */
3072 private static function get_activate_site_theme_input_schema(): array {
3073 return array(
3074 'type' => 'object',
3075 'required' => array( 'site_id_or_domain', 'theme' ),
3076 'properties' => array(
3077 'site_id_or_domain' => array(
3078 'type' => array( 'integer', 'string' ),
3079 'description' => __( 'Site ID or domain', 'mainwp' ),
3080 ),
3081 'theme' => array(
3082 'type' => 'string',
3083 'description' => __( 'Theme slug to activate', 'mainwp' ),
3084 ),
3085 ),
3086 );
3087 }
3088
3089 /**
3090 * Get output schema for activate-theme-v1.
3091 *
3092 * @return array JSON Schema.
3093 */
3094 private static function get_activate_theme_output_schema(): array {
3095 return array(
3096 'type' => 'object',
3097 'properties' => array(
3098 'activated' => array( 'type' => 'boolean' ),
3099 'theme' => array( 'type' => 'object' ),
3100 ),
3101 );
3102 }
3103
3104 /**
3105 * Execute activate-site-theme-v1 ability.
3106 *
3107 * @param array $input Input parameters.
3108 * @return array|\WP_Error Result or error.
3109 */
3110 public static function execute_activate_site_theme( $input ) {
3111 $input = MainWP_Abilities_Util::normalize_input( $input );
3112
3113 $site = MainWP_Abilities_Util::resolve_site( $input['site_id_or_domain'] ?? null );
3114 if ( is_wp_error( $site ) ) {
3115 return $site;
3116 }
3117
3118 $access_check = MainWP_Abilities_Util::check_site_access( $site );
3119 if ( is_wp_error( $access_check ) ) {
3120 return $access_check;
3121 }
3122
3123 // Check child version for remote theme operations.
3124 $version_check = MainWP_Abilities_Util::check_child_version( $site );
3125 if ( is_wp_error( $version_check ) ) {
3126 return $version_check;
3127 }
3128
3129 $theme_slug = isset( $input['theme'] ) ? sanitize_text_field( $input['theme'] ) : '';
3130 if ( empty( $theme_slug ) ) {
3131 return new \WP_Error(
3132 'mainwp_invalid_input',
3133 __( 'Theme slug is required.', 'mainwp' ),
3134 array( 'status' => 400 )
3135 );
3136 }
3137
3138 $result = MainWP_Connect::fetch_url_authed(
3139 $site,
3140 'theme_action',
3141 array(
3142 'action' => 'activate',
3143 'theme' => $theme_slug,
3144 )
3145 );
3146
3147 if ( ! MainWP_Abilities_Util::is_child_response_success( $result ) ) {
3148 $message = is_array( $result ) && isset( $result['error'] ) ? $result['error'] : __( 'Activation failed', 'mainwp' );
3149 return new \WP_Error( 'mainwp_activation_failed', $message, array( 'status' => 500 ) );
3150 }
3151
3152 return array(
3153 'activated' => true,
3154 'theme' => MainWP_Abilities_Util::format_theme_for_output(
3155 array(
3156 'slug' => $theme_slug,
3157 'Name' => $theme_slug,
3158 )
3159 ),
3160 );
3161 }
3162
3163 /**
3164 * Register mainwp/delete-site-themes-v1 ability.
3165 *
3166 * @return void
3167 */
3168 private static function register_delete_site_themes(): void {
3169 wp_register_ability(
3170 'mainwp/delete-site-themes-v1',
3171 array(
3172 'label' => __( 'Delete Site Themes', 'mainwp' ),
3173 'description' => __( 'Delete themes from a MainWP child site. Requires confirmation. Supports dry-run mode.', 'mainwp' ),
3174 'category' => 'mainwp-sites',
3175 'input_schema' => self::get_delete_site_themes_input_schema(),
3176 'output_schema' => self::get_delete_themes_output_schema(),
3177 'execute_callback' => array( self::class, 'execute_delete_site_themes' ),
3178 'permission_callback' => array( MainWP_Abilities_Util::class, 'check_manage_sites_permission' ),
3179 'meta' => array(
3180 'show_in_rest' => true,
3181 'annotations' => array(
3182 'instructions' => 'Destructive operation - requires confirm:true or dry_run:true. Only call when user explicitly requests deletion. Permanently removes themes from the child site.',
3183 'readonly' => false,
3184 'destructive' => true,
3185 'idempotent' => false,
3186 ),
3187 ),
3188 )
3189 );
3190 }
3191
3192 /**
3193 * Get input schema for delete-site-themes-v1.
3194 *
3195 * @return array JSON Schema.
3196 */
3197 private static function get_delete_site_themes_input_schema(): array {
3198 return array(
3199 'type' => 'object',
3200 'required' => array( 'site_id_or_domain', 'themes' ),
3201 'properties' => array(
3202 'site_id_or_domain' => array(
3203 'type' => array( 'integer', 'string' ),
3204 'description' => __( 'Site ID or domain', 'mainwp' ),
3205 ),
3206 'themes' => array(
3207 'type' => 'array',
3208 'description' => __( 'Theme slugs to delete', 'mainwp' ),
3209 'items' => array( 'type' => 'string' ),
3210 'minItems' => 1,
3211 ),
3212 'confirm' => array(
3213 'type' => 'boolean',
3214 'description' => __( 'Must be true to execute deletion', 'mainwp' ),
3215 'default' => false,
3216 ),
3217 'dry_run' => array(
3218 'type' => 'boolean',
3219 'description' => __( 'Preview mode', 'mainwp' ),
3220 'default' => false,
3221 ),
3222 ),
3223 );
3224 }
3225
3226 /**
3227 * Get output schema for delete-site-themes-v1.
3228 *
3229 * @return array JSON Schema.
3230 */
3231 private static function get_delete_themes_output_schema(): array { //phpcs:ignore -- NOSONAR - identical acceptance.
3232 return array(
3233 'type' => 'object',
3234 'properties' => array(
3235 'dry_run' => array( 'type' => 'boolean' ),
3236 'would_affect' => array(
3237 'type' => 'array',
3238 'items' => array( 'type' => 'object' ),
3239 ),
3240 'count' => array( 'type' => 'integer' ),
3241 'warnings' => array(
3242 'type' => 'array',
3243 'items' => array( 'type' => 'string' ),
3244 ),
3245 'deleted' => array(
3246 'type' => 'array',
3247 'items' => array( 'type' => 'object' ),
3248 ),
3249 'errors' => array(
3250 'type' => 'array',
3251 'items' => array( 'type' => 'object' ),
3252 ),
3253 ),
3254 );
3255 }
3256
3257 /**
3258 * Execute delete-site-themes-v1 ability.
3259 *
3260 * @param array $input Input parameters.
3261 * @return array|\WP_Error Result or error.
3262 */
3263 public static function execute_delete_site_themes( $input ) {
3264 return self::execute_delete_remote_items(
3265 $input,
3266 array(
3267 'input_key' => 'themes',
3268 'site_property' => 'themes',
3269 'remote_action' => 'theme_action',
3270 'remote_param' => 'theme',
3271 'formatter' => array( MainWP_Abilities_Util::class, 'format_theme_for_output' ),
3272 'empty_error' => __( 'No themes specified.', 'mainwp' ),
3273 /* translators: %s: theme name */
3274 'active_warning' => __( 'Theme %s is the currently active theme.', 'mainwp' ),
3275 'active_singular' => true,
3276 )
3277 );
3278 }
3279
3280 /**
3281 * Register mainwp/get-abandoned-themes-v1 ability.
3282 *
3283 * @return void
3284 */
3285 private static function register_get_abandoned_themes(): void {
3286 wp_register_ability(
3287 'mainwp/get-abandoned-themes-v1',
3288 array(
3289 'label' => __( 'Get Abandoned Themes', 'mainwp' ),
3290 'description' => __( 'Get list of abandoned themes on a child site. Returns themes detected during last sync. Data freshness depends on sync frequency.', 'mainwp' ),
3291 'category' => 'mainwp-sites',
3292 'input_schema' => self::get_single_site_input_schema(),
3293 'output_schema' => self::get_abandoned_themes_output_schema(),
3294 'execute_callback' => array( self::class, 'execute_get_abandoned_themes' ),
3295 'permission_callback' => MainWP_Abilities_Util::make_site_permission_callback( 'site_id_or_domain' ),
3296 'meta' => array(
3297 'show_in_rest' => true,
3298 'annotations' => array(
3299 'instructions' => '',
3300 'readonly' => true,
3301 'destructive' => false,
3302 'idempotent' => true,
3303 ),
3304 ),
3305 )
3306 );
3307 }
3308
3309 /**
3310 * Get output schema for get-abandoned-themes-v1.
3311 *
3312 * @return array JSON Schema.
3313 */
3314 private static function get_abandoned_themes_output_schema(): array {
3315 return array(
3316 'type' => 'object',
3317 'properties' => array(
3318 'themes' => array(
3319 'type' => 'array',
3320 'items' => array(
3321 'type' => 'object',
3322 'properties' => array(
3323 'slug' => array( 'type' => 'string' ),
3324 'name' => array( 'type' => 'string' ),
3325 'version' => array( 'type' => 'string' ),
3326 'last_updated' => array( 'type' => 'string' ),
3327 'days_since_update' => array( 'type' => array( 'integer', 'null' ) ),
3328 ),
3329 ),
3330 ),
3331 'total' => array( 'type' => 'integer' ),
3332 ),
3333 );
3334 }
3335
3336 /**
3337 * Execute get-abandoned-themes-v1 ability.
3338 *
3339 * @param array $input Input parameters.
3340 * @return array|\WP_Error Result or error.
3341 */
3342 public static function execute_get_abandoned_themes( $input ) { // phpcs:ignore -- NOSONAR - complexity.
3343 $input = MainWP_Abilities_Util::normalize_input( $input );
3344
3345 $site = MainWP_Abilities_Util::resolve_site( $input['site_id_or_domain'] ?? null );
3346 if ( is_wp_error( $site ) ) {
3347 return $site;
3348 }
3349
3350 $access_check = MainWP_Abilities_Util::check_site_access( $site );
3351 if ( is_wp_error( $access_check ) ) {
3352 return $access_check;
3353 }
3354
3355 $abandoned_data = MainWP_DB::instance()->get_website_option( $site, 'themes_outdate_info' );
3356 $abandoned = ! empty( $abandoned_data ) ? json_decode( $abandoned_data, true ) : array();
3357
3358 if ( ! is_array( $abandoned ) ) {
3359 $abandoned = array();
3360 }
3361
3362 $themes = array();
3363 foreach ( $abandoned as $slug => $info ) {
3364 // Convert timestamp to ISO 8601 date string if numeric.
3365 $last_updated = isset( $info['last_updated'] ) ? $info['last_updated'] : '';
3366 if ( is_numeric( $last_updated ) ) {
3367 $last_updated = gmdate( 'c', (int) $last_updated );
3368 }
3369
3370 // Calculate days since update only if we have a valid numeric timestamp.
3371 $days_since_update = null;
3372 if ( isset( $info['outdate_timestamp'] ) && is_numeric( $info['outdate_timestamp'] ) && $info['outdate_timestamp'] > 0 ) {
3373 $days_since_update = (int) ( ( time() - $info['outdate_timestamp'] ) / DAY_IN_SECONDS );
3374 }
3375
3376 $themes[] = array(
3377 'slug' => $slug,
3378 'name' => isset( $info['Name'] ) ? $info['Name'] : $slug,
3379 'version' => isset( $info['Version'] ) ? $info['Version'] : '',
3380 'last_updated' => (string) $last_updated,
3381 'days_since_update' => $days_since_update,
3382 );
3383 }
3384
3385 return array(
3386 'themes' => $themes,
3387 'total' => count( $themes ),
3388 );
3389 }
3390
3391 // =========================================================================
3392 // Security & Monitoring Abilities (2)
3393 // =========================================================================
3394
3395 /**
3396 * Register mainwp/get-site-security-v1 ability.
3397 *
3398 * @return void
3399 */
3400 private static function register_get_site_security(): void {
3401 wp_register_ability(
3402 'mainwp/get-site-security-v1',
3403 array(
3404 'label' => __( 'Get Site Security', 'mainwp' ),
3405 'description' => __( 'Get security status for a MainWP child site including vulnerability counts, security issues, and issue categories detected during last sync.', 'mainwp' ),
3406 'category' => 'mainwp-sites',
3407 'input_schema' => self::get_single_site_input_schema(),
3408 'output_schema' => self::get_site_security_output_schema(),
3409 'execute_callback' => array( self::class, 'execute_get_site_security' ),
3410 'permission_callback' => MainWP_Abilities_Util::make_site_permission_callback( 'site_id_or_domain' ),
3411 'meta' => array(
3412 'show_in_rest' => true,
3413 'annotations' => array(
3414 'instructions' => 'Returns security data from last sync. Does NOT perform real-time scanning. Requires Security module on child site for full data.',
3415 'readonly' => true,
3416 'destructive' => false,
3417 'idempotent' => true,
3418 ),
3419 ),
3420 )
3421 );
3422 }
3423
3424 /**
3425 * Get output schema for get-site-security-v1.
3426 *
3427 * @return array JSON Schema.
3428 */
3429 private static function get_site_security_output_schema(): array {
3430 return array(
3431 'type' => 'object',
3432 'properties' => array(
3433 'site_id' => array( 'type' => 'integer' ),
3434 'security_issues' => array( 'type' => 'object' ),
3435 'total_issues' => array( 'type' => 'integer' ),
3436 ),
3437 );
3438 }
3439
3440 /**
3441 * Execute get-site-security-v1 ability.
3442 *
3443 * @param array $input Input parameters.
3444 * @return array|\WP_Error Result or error.
3445 */
3446 public static function execute_get_site_security( $input ) {
3447 $input = MainWP_Abilities_Util::normalize_input( $input );
3448
3449 $site = MainWP_Abilities_Util::resolve_site( $input['site_id_or_domain'] ?? null );
3450 if ( is_wp_error( $site ) ) {
3451 return $site;
3452 }
3453
3454 $access_check = MainWP_Abilities_Util::check_site_access( $site );
3455 if ( is_wp_error( $access_check ) ) {
3456 return $access_check;
3457 }
3458
3459 $security_data = MainWP_DB::instance()->get_website_option( $site, 'security_stats' );
3460 $security_issues = ! empty( $security_data ) ? json_decode( $security_data, true ) : array();
3461
3462 if ( ! is_array( $security_issues ) ) {
3463 $security_issues = array();
3464 }
3465
3466 $total_issues = 0;
3467 foreach ( $security_issues as $value ) {
3468 if ( 'N' === $value || '0' === $value || 0 === $value ) {
3469 ++$total_issues;
3470 }
3471 }
3472
3473 return array(
3474 'site_id' => (int) $site->id,
3475 'security_issues' => $security_issues,
3476 'total_issues' => $total_issues,
3477 );
3478 }
3479
3480 /**
3481 * Register mainwp/get-site-changes-v1 ability.
3482 *
3483 * @return void
3484 */
3485 private static function register_get_site_changes(): void {
3486 wp_register_ability(
3487 'mainwp/get-site-changes-v1',
3488 array(
3489 'label' => __( 'Get Site Changes', 'mainwp' ),
3490 'description' => __( 'Get non-MainWP changes detected on a child site. Requires Logs module.', 'mainwp' ),
3491 'category' => 'mainwp-sites',
3492 'input_schema' => self::get_site_changes_input_schema(),
3493 'output_schema' => self::get_site_changes_output_schema(),
3494 'execute_callback' => array( self::class, 'execute_get_site_changes' ),
3495 'permission_callback' => MainWP_Abilities_Util::make_site_permission_callback( 'site_id_or_domain' ),
3496 'meta' => array(
3497 'show_in_rest' => true,
3498 'annotations' => array(
3499 'instructions' => 'Detects changes made outside MainWP (direct edits, other plugins). Requires Logs module enabled. Results paginated (default 20, max 100).',
3500 'readonly' => true,
3501 'destructive' => false,
3502 'idempotent' => true,
3503 ),
3504 ),
3505 )
3506 );
3507 }
3508
3509 /**
3510 * Get input schema for get-site-changes-v1.
3511 *
3512 * @return array JSON Schema.
3513 */
3514 private static function get_site_changes_input_schema(): array {
3515 return array(
3516 'type' => 'object',
3517 'required' => array( 'site_id_or_domain' ),
3518 'properties' => array(
3519 'site_id_or_domain' => array(
3520 'type' => array( 'integer', 'string' ),
3521 'description' => __( 'Site ID or domain', 'mainwp' ),
3522 ),
3523 'page' => array(
3524 'type' => 'integer',
3525 'description' => __( 'Page number', 'mainwp' ),
3526 'default' => 1,
3527 'minimum' => 1,
3528 ),
3529 'per_page' => array(
3530 'type' => 'integer',
3531 'description' => __( 'Items per page', 'mainwp' ),
3532 'default' => 20,
3533 'minimum' => 1,
3534 'maximum' => 100,
3535 ),
3536 'type' => array(
3537 'type' => 'string',
3538 'description' => __( 'Filter by change type', 'mainwp' ),
3539 ),
3540 ),
3541 );
3542 }
3543
3544 /**
3545 * Get output schema for get-site-changes-v1.
3546 *
3547 * @return array JSON Schema.
3548 */
3549 private static function get_site_changes_output_schema(): array {
3550 return array(
3551 'type' => 'object',
3552 'properties' => array(
3553 'items' => array(
3554 'type' => 'array',
3555 'items' => array( 'type' => 'object' ),
3556 ),
3557 'page' => array( 'type' => 'integer' ),
3558 'per_page' => array( 'type' => 'integer' ),
3559 'total' => array( 'type' => 'integer' ),
3560 ),
3561 );
3562 }
3563
3564 /**
3565 * Execute get-site-changes-v1 ability.
3566 *
3567 * @param array $input Input parameters.
3568 * @return array|\WP_Error Result or error.
3569 */
3570 public static function execute_get_site_changes( $input ) { // phpcs:ignore -- NOSONAR - complexity.
3571 $input = MainWP_Abilities_Util::normalize_input(
3572 $input,
3573 array(
3574 'page' => 1,
3575 'per_page' => 20,
3576 )
3577 );
3578
3579 $site = MainWP_Abilities_Util::resolve_site( $input['site_id_or_domain'] ?? null );
3580 if ( is_wp_error( $site ) ) {
3581 return $site;
3582 }
3583
3584 $access_check = MainWP_Abilities_Util::check_site_access( $site );
3585 if ( is_wp_error( $access_check ) ) {
3586 return $access_check;
3587 }
3588
3589 if ( ! class_exists( 'MainWP\Dashboard\Module\Log\Log_Manager' ) ) {
3590 return new \WP_Error(
3591 'mainwp_module_not_available',
3592 __( 'Logs module is not active.', 'mainwp' ),
3593 array( 'status' => 501 )
3594 );
3595 }
3596
3597 $page = max( 1, (int) $input['page'] );
3598 $per_page = max( 1, min( 100, (int) $input['per_page'] ) );
3599 $start = ( $page - 1 ) * $per_page;
3600
3601 // Build query arguments for Log_Query.
3602 $query_args = array(
3603 'wpid' => (int) $site->id,
3604 'sources_conds' => 'wp-admin-only', // Only non-MainWP changes.
3605 'start' => $start,
3606 'records_per_page' => $per_page,
3607 'order' => 'DESC',
3608 'orderby' => 'created',
3609 'check_access' => false, // Already checked above.
3610 'optimize' => true,
3611 );
3612
3613 // Optional type filter (maps to context).
3614 if ( ! empty( $input['type'] ) ) {
3615 $query_args['contexts'] = sanitize_text_field( $input['type'] );
3616 }
3617
3618 // Use Log_Manager to ensure proper table name initialization.
3619 $log_db = \MainWP\Dashboard\Module\Log\Log_Manager::instance()->db;
3620 $records = $log_db->query( $query_args );
3621 $total = $log_db->get_found_records_count();
3622
3623 // Format records for output.
3624 $items = array();
3625 if ( is_array( $records ) ) {
3626 foreach ( $records as $record ) {
3627 $items[] = array(
3628 'id' => isset( $record->log_id ) ? (int) $record->log_id : 0,
3629 'type' => isset( $record->context ) ? $record->context : '',
3630 'action' => isset( $record->action ) ? $record->action : '',
3631 'item' => isset( $record->item ) ? $record->item : '',
3632 'user_id' => isset( $record->user_id ) ? (int) $record->user_id : 0,
3633 'user' => isset( $record->user_login ) ? $record->user_login : '',
3634 'timestamp' => isset( $record->created ) ? gmdate( 'c', (int) ( $record->created / 1000000 ) ) : '',
3635 );
3636 }
3637 }
3638
3639 return array(
3640 'items' => $items,
3641 'page' => $page,
3642 'per_page' => $per_page,
3643 'total' => (int) $total,
3644 );
3645 }
3646
3647 // =========================================================================
3648 // Related Data Abilities (4)
3649 // =========================================================================
3650
3651 /**
3652 * Register mainwp/get-site-client-v1 ability.
3653 *
3654 * @return void
3655 */
3656 private static function register_get_site_client(): void {
3657 wp_register_ability(
3658 'mainwp/get-site-client-v1',
3659 array(
3660 'label' => __( 'Get Site Client', 'mainwp' ),
3661 'description' => __( 'Get client assigned to a MainWP child site.', 'mainwp' ),
3662 'category' => 'mainwp-sites',
3663 'input_schema' => self::get_single_site_input_schema(),
3664 'output_schema' => self::get_site_client_output_schema(),
3665 'execute_callback' => array( self::class, 'execute_get_site_client' ),
3666 'permission_callback' => MainWP_Abilities_Util::make_site_permission_callback( 'site_id_or_domain' ),
3667 'meta' => array(
3668 'show_in_rest' => true,
3669 'annotations' => array(
3670 'instructions' => '',
3671 'readonly' => true,
3672 'destructive' => false,
3673 'idempotent' => true,
3674 ),
3675 ),
3676 )
3677 );
3678 }
3679
3680 /**
3681 * Get output schema for get-site-client-v1.
3682 *
3683 * @return array JSON Schema.
3684 */
3685 private static function get_site_client_output_schema(): array {
3686 return array(
3687 'type' => 'object',
3688 'description' => __( 'Client assignment result with client data or null.', 'mainwp' ),
3689 'properties' => array(
3690 'client' => array(
3691 'type' => array( 'object', 'null' ),
3692 'description' => __( 'Client object or null if no client assigned.', 'mainwp' ),
3693 'additionalProperties' => true,
3694 ),
3695 'message' => array(
3696 'type' => 'string',
3697 'description' => __( 'Status message explaining the result.', 'mainwp' ),
3698 ),
3699 ),
3700 'required' => array( 'client' ),
3701 );
3702 }
3703
3704 /**
3705 * Execute get-site-client-v1 ability.
3706 *
3707 * @param array $input Input parameters.
3708 * @return array|\WP_Error Client result object or error.
3709 */
3710 public static function execute_get_site_client( $input ) {
3711 $input = MainWP_Abilities_Util::normalize_input( $input );
3712
3713 $site = MainWP_Abilities_Util::resolve_site( $input['site_id_or_domain'] ?? null );
3714 if ( is_wp_error( $site ) ) {
3715 return $site;
3716 }
3717
3718 $access_check = MainWP_Abilities_Util::check_site_access( $site );
3719 if ( is_wp_error( $access_check ) ) {
3720 return $access_check;
3721 }
3722
3723 $client_id = isset( $site->client_id ) ? (int) $site->client_id : 0;
3724
3725 if ( empty( $client_id ) ) {
3726 return array(
3727 'client' => null,
3728 'message' => __( 'No client assigned to this site.', 'mainwp' ),
3729 );
3730 }
3731
3732 $client = MainWP_DB_Client::instance()->get_wp_client_by( 'client_id', $client_id );
3733
3734 if ( empty( $client ) ) {
3735 return array(
3736 'client' => null,
3737 'message' => __( 'Assigned client not found in database.', 'mainwp' ),
3738 );
3739 }
3740
3741 return array(
3742 'client' => MainWP_Abilities_Util::format_client_for_output( $client ),
3743 'message' => __( 'Client found.', 'mainwp' ),
3744 );
3745 }
3746
3747 /**
3748 * Register mainwp/get-site-costs-v1 ability.
3749 *
3750 * @return void
3751 */
3752 private static function register_get_site_costs(): void {
3753 if ( ! class_exists( 'MainWP\Dashboard\Module\CostTracker\Cost_Tracker_Manager' ) ) {
3754 return;
3755 }
3756
3757 wp_register_ability(
3758 'mainwp/get-site-costs-v1',
3759 array(
3760 'label' => __( 'Get Site Costs', 'mainwp' ),
3761 'description' => __( 'Get costs associated with a MainWP child site. Requires Cost Tracker module to be active.', 'mainwp' ),
3762 'category' => 'mainwp-sites',
3763 'input_schema' => self::get_single_site_input_schema(),
3764 'output_schema' => self::get_site_costs_output_schema(),
3765 'execute_callback' => array( self::class, 'execute_get_site_costs' ),
3766 'permission_callback' => MainWP_Abilities_Util::make_site_permission_callback( 'site_id_or_domain' ),
3767 'meta' => array(
3768 'show_in_rest' => true,
3769 'annotations' => array(
3770 'instructions' => '',
3771 'readonly' => true,
3772 'destructive' => false,
3773 'idempotent' => true,
3774 ),
3775 ),
3776 )
3777 );
3778 }
3779
3780 /**
3781 * Get output schema for get-site-costs-v1.
3782 *
3783 * @return array JSON Schema.
3784 */
3785 private static function get_site_costs_output_schema(): array {
3786 return array(
3787 'type' => 'object',
3788 'properties' => array(
3789 'site_id' => array(
3790 'type' => 'integer',
3791 'description' => __( 'Site ID', 'mainwp' ),
3792 ),
3793 'costs' => array(
3794 'type' => 'array',
3795 'items' => array( 'type' => 'object' ),
3796 ),
3797 'total' => array( 'type' => 'integer' ),
3798 ),
3799 );
3800 }
3801
3802 /**
3803 * Execute get-site-costs-v1 ability.
3804 *
3805 * @param array $input Input parameters.
3806 * @return array|\WP_Error Result or error.
3807 */
3808 public static function execute_get_site_costs( $input ) {
3809 // Defensive check: module may have become unavailable after registration.
3810 if ( ! class_exists( 'MainWP\Dashboard\Module\CostTracker\Cost_Tracker_Manager' ) ) {
3811 return new \WP_Error(
3812 'mainwp_module_not_available',
3813 __( 'Cost Tracker module is not active.', 'mainwp' ),
3814 array( 'status' => 501 )
3815 );
3816 }
3817
3818 $input = MainWP_Abilities_Util::normalize_input( $input );
3819
3820 $site = MainWP_Abilities_Util::resolve_site( $input['site_id_or_domain'] ?? null );
3821 if ( is_wp_error( $site ) ) {
3822 return $site;
3823 }
3824
3825 $access_check = MainWP_Abilities_Util::check_site_access( $site );
3826 if ( is_wp_error( $access_check ) ) {
3827 return $access_check;
3828 }
3829
3830 $costs_data = \MainWP\Dashboard\Module\CostTracker\Cost_Tracker_DB::get_instance()->get_all_cost_trackers_by_sites( array( $site->id ) );
3831
3832 $costs = array();
3833 if ( is_array( $costs_data ) ) {
3834 foreach ( $costs_data as $cost ) {
3835 $costs[] = MainWP_Abilities_Util::format_cost_for_output( $cost );
3836 }
3837 }
3838
3839 return array(
3840 'site_id' => (int) $site->id,
3841 'costs' => $costs,
3842 'total' => count( $costs ),
3843 );
3844 }
3845
3846 /**
3847 * Register mainwp/count-sites-v1 ability.
3848 *
3849 * @return void
3850 */
3851 private static function register_count_sites(): void {
3852 wp_register_ability(
3853 'mainwp/count-sites-v1',
3854 array(
3855 'label' => __( 'Count MainWP Sites', 'mainwp' ),
3856 'description' => __( 'Count total MainWP child sites with optional filtering.', 'mainwp' ),
3857 'category' => 'mainwp-sites',
3858 'input_schema' => self::get_count_sites_input_schema(),
3859 'output_schema' => self::get_count_sites_output_schema(),
3860 'execute_callback' => array( self::class, 'execute_count_sites' ),
3861 'permission_callback' => array( MainWP_Abilities_Util::class, 'check_rest_api_permission' ),
3862 'meta' => array(
3863 'show_in_rest' => true,
3864 'annotations' => array(
3865 'instructions' => '',
3866 'readonly' => true,
3867 'destructive' => false,
3868 'idempotent' => true,
3869 ),
3870 ),
3871 )
3872 );
3873 }
3874
3875 /**
3876 * Get input schema for count-sites-v1.
3877 *
3878 * @return array JSON Schema.
3879 */
3880 private static function get_count_sites_input_schema(): array {
3881 return array(
3882 'type' => array( 'object', 'null' ),
3883 'properties' => array(
3884 'status' => array(
3885 'type' => 'string',
3886 'description' => __( 'Filter by status', 'mainwp' ),
3887 'enum' => array( 'connected', 'disconnected', 'suspended' ),
3888 ),
3889 'tag_ids' => array(
3890 'type' => 'array',
3891 'description' => __( 'Filter by tag IDs', 'mainwp' ),
3892 'items' => array( 'type' => 'integer' ),
3893 ),
3894 'client_id' => array(
3895 'type' => 'integer',
3896 'description' => __( 'Filter by client ID', 'mainwp' ),
3897 ),
3898 ),
3899 );
3900 }
3901
3902 /**
3903 * Get output schema for count-sites-v1.
3904 *
3905 * @return array JSON Schema.
3906 */
3907 private static function get_count_sites_output_schema(): array {
3908 return array(
3909 'type' => 'object',
3910 'properties' => array(
3911 'total' => array(
3912 'type' => 'integer',
3913 'description' => __( 'Total count of sites', 'mainwp' ),
3914 ),
3915 ),
3916 );
3917 }
3918
3919 /**
3920 * Execute count-sites-v1 ability.
3921 *
3922 * @param array $input Input parameters.
3923 * @return array Result.
3924 */
3925 public static function execute_count_sites( $input ) {
3926 $input = MainWP_Abilities_Util::normalize_input( $input );
3927
3928 $filters = array();
3929
3930 if ( ! empty( $input['status'] ) ) {
3931 $filters['status'] = $input['status'];
3932 }
3933
3934 if ( ! empty( $input['tag_ids'] ) && is_array( $input['tag_ids'] ) ) {
3935 $filters['tags'] = $input['tag_ids'];
3936 }
3937
3938 if ( ! empty( $input['client_id'] ) ) {
3939 $filters['client_id'] = (int) $input['client_id'];
3940 }
3941
3942 $total = MainWP_DB::instance()->get_websites_count_for_current_user( $filters );
3943
3944 return array(
3945 'total' => (int) $total,
3946 );
3947 }
3948
3949 /**
3950 * Register mainwp/get-sites-basic-v1 ability.
3951 *
3952 * @return void
3953 */
3954 private static function register_get_sites_basic(): void {
3955 wp_register_ability(
3956 'mainwp/get-sites-basic-v1',
3957 array(
3958 'label' => __( 'Get Sites Basic', 'mainwp' ),
3959 'description' => __( 'Get basic site info (id, url, name only) for fast bulk retrieval. Use list_sites_v1 for full details with filtering, or get_site_v1 for complete single-site information.', 'mainwp' ),
3960 'category' => 'mainwp-sites',
3961 'input_schema' => self::get_sites_basic_input_schema(),
3962 'output_schema' => self::get_sites_basic_output_schema(),
3963 'execute_callback' => array( self::class, 'execute_get_sites_basic' ),
3964 'permission_callback' => array( MainWP_Abilities_Util::class, 'check_rest_api_permission' ),
3965 'meta' => array(
3966 'show_in_rest' => true,
3967 'annotations' => array(
3968 'instructions' => '',
3969 'readonly' => true,
3970 'destructive' => false,
3971 'idempotent' => true,
3972 ),
3973 ),
3974 )
3975 );
3976 }
3977
3978 /**
3979 * Get input schema for get-sites-basic-v1.
3980 *
3981 * @return array JSON Schema.
3982 */
3983 private static function get_sites_basic_input_schema(): array {
3984 return array(
3985 'type' => array( 'object', 'null' ),
3986 'properties' => array(
3987 'page' => array(
3988 'type' => 'integer',
3989 'description' => __( 'Page number', 'mainwp' ),
3990 'default' => 1,
3991 'minimum' => 1,
3992 ),
3993 'per_page' => array(
3994 'type' => 'integer',
3995 'description' => __( 'Items per page', 'mainwp' ),
3996 'default' => 20,
3997 'minimum' => 1,
3998 'maximum' => 100,
3999 ),
4000 'status' => array(
4001 'type' => 'string',
4002 'enum' => array( 'connected', 'disconnected', 'suspended' ),
4003 ),
4004 'tag_ids' => array(
4005 'type' => 'array',
4006 'items' => array( 'type' => 'integer' ),
4007 ),
4008 'client_id' => array( 'type' => 'integer' ),
4009 ),
4010 );
4011 }
4012
4013 /**
4014 * Get output schema for get-sites-basic-v1.
4015 *
4016 * @return array JSON Schema.
4017 */
4018 private static function get_sites_basic_output_schema(): array {
4019 return array(
4020 'type' => 'object',
4021 'properties' => array(
4022 'items' => array(
4023 'type' => 'array',
4024 'items' => array(
4025 'type' => 'object',
4026 'properties' => array(
4027 'id' => array( 'type' => 'integer' ),
4028 'url' => array( 'type' => 'string' ),
4029 'name' => array( 'type' => 'string' ),
4030 ),
4031 ),
4032 ),
4033 'page' => array( 'type' => 'integer' ),
4034 'per_page' => array( 'type' => 'integer' ),
4035 'total' => array( 'type' => 'integer' ),
4036 ),
4037 );
4038 }
4039
4040 /**
4041 * Execute get-sites-basic-v1 ability.
4042 *
4043 * @param array $input Input parameters.
4044 * @return array Result.
4045 */
4046 public static function execute_get_sites_basic( $input ) {
4047 $input = MainWP_Abilities_Util::normalize_input(
4048 $input,
4049 array(
4050 'page' => 1,
4051 'per_page' => 20,
4052 )
4053 );
4054
4055 $page = max( 1, (int) $input['page'] );
4056 $per_page = max( 1, min( 100, (int) $input['per_page'] ) );
4057
4058 $filters = array(
4059 'offset' => ( $page - 1 ) * $per_page,
4060 'rowcount' => $per_page,
4061 'selectgroups' => false,
4062 );
4063
4064 if ( ! empty( $input['status'] ) ) {
4065 $filters['status'] = $input['status'];
4066 }
4067
4068 if ( ! empty( $input['tag_ids'] ) && is_array( $input['tag_ids'] ) ) {
4069 $filters['tags'] = $input['tag_ids'];
4070 }
4071
4072 if ( ! empty( $input['client_id'] ) ) {
4073 $filters['client_id'] = (int) $input['client_id'];
4074 }
4075
4076 $sites = MainWP_DB::instance()->get_websites_for_current_user( $filters );
4077
4078 if ( is_wp_error( $sites ) ) {
4079 return $sites;
4080 }
4081
4082 $total = MainWP_DB::instance()->get_websites_count_for_current_user( $filters );
4083
4084 if ( is_wp_error( $total ) ) {
4085 return $total;
4086 }
4087
4088 $items = array();
4089 foreach ( $sites as $site ) {
4090 $items[] = array(
4091 'id' => (int) $site->id,
4092 'url' => $site->url,
4093 'name' => MainWP_Utility::remove_http_prefix( (string) $site->name, true ),
4094 );
4095 }
4096
4097 return array(
4098 'items' => $items,
4099 'page' => $page,
4100 'per_page' => $per_page,
4101 'total' => (int) $total,
4102 );
4103 }
4104
4105 // =========================================================================
4106 // Batch Operations Abilities (4)
4107 // =========================================================================
4108
4109 /**
4110 * Register mainwp/reconnect-sites-v1 ability.
4111 *
4112 * @return void
4113 */
4114 private static function register_reconnect_sites(): void {
4115 wp_register_ability(
4116 'mainwp/reconnect-sites-v1',
4117 array(
4118 'label' => __( 'Reconnect MainWP Sites', 'mainwp' ),
4119 'description' => __( 'Reconnect multiple MainWP child sites. Operations with >200 sites are automatically queued.', 'mainwp' ),
4120 'category' => 'mainwp-sites',
4121 'input_schema' => self::get_batch_sites_input_schema(),
4122 'output_schema' => self::get_batch_operation_output_schema(),
4123 'execute_callback' => array( self::class, 'execute_reconnect_sites' ),
4124 'permission_callback' => array( MainWP_Abilities_Util::class, 'check_manage_sites_permission' ),
4125 'meta' => array(
4126 'show_in_rest' => true,
4127 'annotations' => array(
4128 'instructions' => 'Pass site_ids_or_domains with specific IDs, or empty array for all applicable sites. Operations with >200 sites are automatically queued for background processing.',
4129 'readonly' => false,
4130 'destructive' => false,
4131 'idempotent' => true,
4132 ),
4133 ),
4134 )
4135 );
4136 }
4137
4138 /**
4139 * Get input schema for batch site operations.
4140 *
4141 * @return array JSON Schema.
4142 */
4143 private static function get_batch_sites_input_schema(): array {
4144 return array(
4145 'type' => array( 'object', 'null' ),
4146 'properties' => array(
4147 'site_ids_or_domains' => array(
4148 'type' => 'array',
4149 'description' => __( 'Site IDs or domains. Empty array means all sites.', 'mainwp' ),
4150 'items' => array(
4151 'type' => array( 'integer', 'string' ),
4152 ),
4153 'default' => array(),
4154 ),
4155 ),
4156 'additionalProperties' => false,
4157 );
4158 }
4159
4160 /**
4161 * Get output schema for batch operations.
4162 *
4163 * @return array JSON Schema.
4164 */
4165 private static function get_batch_operation_output_schema(): array {
4166 return array(
4167 'type' => 'object',
4168 'properties' => array(
4169 'queued' => array( 'type' => 'boolean' ),
4170 'job_id' => array( 'type' => 'string' ),
4171 'total' => array( 'type' => 'integer' ),
4172 'reconnected' => array(
4173 'type' => 'array',
4174 'items' => array( 'type' => 'object' ),
4175 ),
4176 'disconnected' => array(
4177 'type' => 'array',
4178 'items' => array( 'type' => 'object' ),
4179 ),
4180 'checked' => array(
4181 'type' => 'array',
4182 'items' => array( 'type' => 'object' ),
4183 ),
4184 'suspended' => array(
4185 'type' => 'array',
4186 'items' => array( 'type' => 'object' ),
4187 ),
4188 'errors' => array(
4189 'type' => 'array',
4190 'items' => array( 'type' => 'object' ),
4191 ),
4192 'total_reconnected' => array( 'type' => 'integer' ),
4193 'total_disconnected' => array( 'type' => 'integer' ),
4194 'total_checked' => array( 'type' => 'integer' ),
4195 'total_suspended' => array( 'type' => 'integer' ),
4196 'total_errors' => array( 'type' => 'integer' ),
4197 ),
4198 );
4199 }
4200
4201 /**
4202 * Execute reconnect-sites-v1 ability.
4203 *
4204 * @param array $input Input parameters.
4205 * @return array|\WP_Error Result or error.
4206 */
4207 public static function execute_reconnect_sites( $input ) { // phpcs:ignore -- NOSONAR - complexity.
4208 $input = MainWP_Abilities_Util::normalize_input( $input );
4209
4210 // Core schema validation coerce-accepts scalars for type:array but hands the raw
4211 // value through; a non-array must not fall into the all-sites branch below.
4212 // array_key_exists, not isset: an explicit null is a present non-array too.
4213 if ( array_key_exists( 'site_ids_or_domains', $input ) && ! is_array( $input['site_ids_or_domains'] ) ) {
4214 return new \WP_Error(
4215 'mainwp_invalid_input',
4216 __( 'The site_ids_or_domains parameter must be an array of site IDs or domains.', 'mainwp' ),
4217 array( 'status' => 400 )
4218 );
4219 }
4220
4221 $identifiers = isset( $input['site_ids_or_domains'] ) ? $input['site_ids_or_domains'] : array();
4222
4223 // If empty, get all sites for current user.
4224 if ( empty( $identifiers ) ) {
4225 $all_sites = MainWP_DB::instance()->get_websites_for_current_user( array( 'selectgroups' => false ) );
4226
4227 // Handle potential errors from DB query.
4228 if ( is_wp_error( $all_sites ) ) {
4229 return $all_sites;
4230 }
4231
4232 $identifiers = array_map(
4233 function ( $s ) {
4234 return (int) $s->id;
4235 },
4236 $all_sites ? $all_sites : array()
4237 );
4238 }
4239
4240 // Resolve sites and enforce per-site ACLs.
4241 $access = MainWP_Abilities_Util::check_batch_site_access( $identifiers, $input );
4242 $sites = $access['allowed'];
4243 $errors = $access['denied']; // Resolution + access-denied errors.
4244
4245 if ( empty( $sites ) ) {
4246 return new \WP_Error(
4247 'mainwp_site_not_found',
4248 __( 'No valid sites found.', 'mainwp' ),
4249 array( 'status' => 404 )
4250 );
4251 }
4252
4253 $threshold = apply_filters( 'mainwp_abilities_batch_threshold', 200 );
4254
4255 if ( count( $sites ) > $threshold ) {
4256 $job_id = MainWP_Abilities_Util::queue_batch_operation( 'reconnect', $sites );
4257 if ( is_wp_error( $job_id ) ) {
4258 return $job_id;
4259 }
4260 return array(
4261 'queued' => true,
4262 'job_id' => $job_id,
4263 'total' => count( $sites ),
4264 'errors' => $errors, // Include resolution + ACL errors in queued response.
4265 );
4266 }
4267
4268 $reconnected = array();
4269
4270 foreach ( $sites as $site ) {
4271 // Use the View method directly (not AJAX handler which calls die()).
4272 try {
4273 $result = MainWP_Manage_Sites_View::m_reconnect_site( $site );
4274 } catch ( \Exception $e ) {
4275 $errors[] = array(
4276 'identifier' => $site->url,
4277 'code' => 'mainwp_reconnect_failed',
4278 'message' => $e->getMessage(),
4279 );
4280 continue;
4281 }
4282
4283 if ( ! $result ) {
4284 $errors[] = array(
4285 'identifier' => $site->url,
4286 'code' => 'mainwp_reconnect_failed',
4287 'message' => __( 'Site reconnection failed.', 'mainwp' ),
4288 );
4289 } else {
4290 $reconnected[] = array(
4291 'id' => (int) $site->id,
4292 'url' => $site->url,
4293 'name' => MainWP_Utility::remove_http_prefix( (string) $site->name, true ),
4294 );
4295 }
4296 }
4297
4298 return array(
4299 'reconnected' => $reconnected,
4300 'errors' => $errors,
4301 'total_reconnected' => count( $reconnected ),
4302 'total_errors' => count( $errors ),
4303 );
4304 }
4305
4306 /**
4307 * Register mainwp/disconnect-sites-v1 ability.
4308 *
4309 * @return void
4310 */
4311 private static function register_disconnect_sites(): void {
4312 wp_register_ability(
4313 'mainwp/disconnect-sites-v1',
4314 array(
4315 'label' => __( 'Disconnect MainWP Sites', 'mainwp' ),
4316 'description' => __( 'Disconnect multiple MainWP child sites. Operations with >200 sites are automatically queued.', 'mainwp' ),
4317 'category' => 'mainwp-sites',
4318 'input_schema' => self::get_batch_sites_input_schema(),
4319 'output_schema' => self::get_batch_operation_output_schema(),
4320 'execute_callback' => array( self::class, 'execute_disconnect_sites' ),
4321 'permission_callback' => array( MainWP_Abilities_Util::class, 'check_manage_sites_permission' ),
4322 'meta' => array(
4323 'show_in_rest' => true,
4324 'annotations' => array(
4325 'instructions' => 'Pass site_ids_or_domains with specific IDs, or empty array for all applicable sites. Operations with >200 sites are automatically queued for background processing.',
4326 'readonly' => false,
4327 'destructive' => false,
4328 'idempotent' => true,
4329 ),
4330 ),
4331 )
4332 );
4333 }
4334
4335 /**
4336 * Execute disconnect-sites-v1 ability.
4337 *
4338 * @param array $input Input parameters.
4339 * @return array|\WP_Error Result or error.
4340 */
4341 public static function execute_disconnect_sites( $input ) {
4342 $input = MainWP_Abilities_Util::normalize_input( $input );
4343
4344 // Core schema validation coerce-accepts scalars for type:array but hands the raw
4345 // value through; a non-array must not fall into the all-sites branch below.
4346 // array_key_exists, not isset: an explicit null is a present non-array too.
4347 if ( array_key_exists( 'site_ids_or_domains', $input ) && ! is_array( $input['site_ids_or_domains'] ) ) {
4348 return new \WP_Error(
4349 'mainwp_invalid_input',
4350 __( 'The site_ids_or_domains parameter must be an array of site IDs or domains.', 'mainwp' ),
4351 array( 'status' => 400 )
4352 );
4353 }
4354
4355 $identifiers = isset( $input['site_ids_or_domains'] ) ? $input['site_ids_or_domains'] : array();
4356
4357 // If empty, get all sites for current user.
4358 if ( empty( $identifiers ) ) {
4359 $all_sites = MainWP_DB::instance()->get_websites_for_current_user( array( 'selectgroups' => false ) );
4360
4361 // Handle potential errors from DB query.
4362 if ( is_wp_error( $all_sites ) ) {
4363 return $all_sites;
4364 }
4365
4366 $identifiers = array_map(
4367 function ( $s ) {
4368 return (int) $s->id;
4369 },
4370 $all_sites ? $all_sites : array()
4371 );
4372 }
4373
4374 // Resolve sites and enforce per-site ACLs.
4375 $access = MainWP_Abilities_Util::check_batch_site_access( $identifiers, $input );
4376 $sites = $access['allowed'];
4377 $errors = $access['denied']; // Resolution + access-denied errors.
4378
4379 if ( empty( $sites ) ) {
4380 return new \WP_Error(
4381 'mainwp_site_not_found',
4382 __( 'No valid sites found.', 'mainwp' ),
4383 array( 'status' => 404 )
4384 );
4385 }
4386
4387 $threshold = apply_filters( 'mainwp_abilities_batch_threshold', 200 );
4388
4389 if ( count( $sites ) > $threshold ) {
4390 $job_id = MainWP_Abilities_Util::queue_batch_operation( 'disconnect', $sites );
4391 if ( is_wp_error( $job_id ) ) {
4392 return $job_id;
4393 }
4394 return array(
4395 'queued' => true,
4396 'job_id' => $job_id,
4397 'total' => count( $sites ),
4398 'errors' => $errors, // Include resolution + ACL errors in queued response.
4399 );
4400 }
4401
4402 $disconnected = array();
4403
4404 foreach ( $sites as $site ) {
4405 MainWP_DB::instance()->update_website_sync_values( $site->id, array( 'sync_errors' => __( 'Manually disconnected', 'mainwp' ) ) );
4406
4407 $disconnected[] = array(
4408 'id' => (int) $site->id,
4409 'url' => $site->url,
4410 'name' => MainWP_Utility::remove_http_prefix( (string) $site->name, true ),
4411 );
4412 }
4413
4414 return array(
4415 'disconnected' => $disconnected,
4416 'errors' => $errors,
4417 'total_disconnected' => count( $disconnected ),
4418 'total_errors' => count( $errors ),
4419 );
4420 }
4421
4422 /**
4423 * Register mainwp/check-sites-v1 ability.
4424 *
4425 * @return void
4426 */
4427 private static function register_check_sites(): void {
4428 wp_register_ability(
4429 'mainwp/check-sites-v1',
4430 array(
4431 'label' => __( 'Check MainWP Sites', 'mainwp' ),
4432 'description' => __( 'Check connectivity status of multiple MainWP child sites. Operations with >200 sites are automatically queued.', 'mainwp' ),
4433 'category' => 'mainwp-sites',
4434 'input_schema' => self::get_batch_sites_input_schema(),
4435 'output_schema' => self::get_batch_operation_output_schema(),
4436 'execute_callback' => array( self::class, 'execute_check_sites' ),
4437 'permission_callback' => array( MainWP_Abilities_Util::class, 'check_rest_api_permission' ),
4438 'meta' => array(
4439 'show_in_rest' => true,
4440 'annotations' => array(
4441 'instructions' => 'Pass site_ids_or_domains with specific IDs, or empty array for all applicable sites. Operations with >200 sites are automatically queued for background processing.',
4442 'readonly' => false,
4443 'destructive' => false,
4444 'idempotent' => true,
4445 ),
4446 ),
4447 )
4448 );
4449 }
4450
4451 /**
4452 * Execute check-sites-v1 ability.
4453 *
4454 * @param array $input Input parameters.
4455 * @return array|\WP_Error Result or error.
4456 */
4457 public static function execute_check_sites( $input ) { // phpcs:ignore -- NOSONAR - complexity.
4458 $input = MainWP_Abilities_Util::normalize_input( $input );
4459
4460 // Core schema validation coerce-accepts scalars for type:array but hands the raw
4461 // value through; a non-array must not fall into the all-sites branch below.
4462 // array_key_exists, not isset: an explicit null is a present non-array too.
4463 if ( array_key_exists( 'site_ids_or_domains', $input ) && ! is_array( $input['site_ids_or_domains'] ) ) {
4464 return new \WP_Error(
4465 'mainwp_invalid_input',
4466 __( 'The site_ids_or_domains parameter must be an array of site IDs or domains.', 'mainwp' ),
4467 array( 'status' => 400 )
4468 );
4469 }
4470
4471 $identifiers = isset( $input['site_ids_or_domains'] ) ? $input['site_ids_or_domains'] : array();
4472
4473 // If empty, get all sites for current user.
4474 if ( empty( $identifiers ) ) {
4475 $all_sites = MainWP_DB::instance()->get_websites_for_current_user( array( 'selectgroups' => false ) );
4476
4477 // Handle potential errors from DB query.
4478 if ( is_wp_error( $all_sites ) ) {
4479 return $all_sites;
4480 }
4481
4482 $identifiers = array_map(
4483 function ( $s ) {
4484 return (int) $s->id;
4485 },
4486 $all_sites ? $all_sites : array()
4487 );
4488 }
4489
4490 // Resolve sites and enforce per-site ACLs.
4491 $access = MainWP_Abilities_Util::check_batch_site_access( $identifiers, $input );
4492 $sites = $access['allowed'];
4493 $errors = $access['denied']; // Resolution + access-denied errors.
4494
4495 if ( empty( $sites ) ) {
4496 return new \WP_Error(
4497 'mainwp_site_not_found',
4498 __( 'No valid sites found.', 'mainwp' ),
4499 array( 'status' => 404 )
4500 );
4501 }
4502
4503 $threshold = apply_filters( 'mainwp_abilities_batch_threshold', 200 );
4504
4505 if ( count( $sites ) > $threshold ) {
4506 $job_id = MainWP_Abilities_Util::queue_batch_operation( 'check', $sites );
4507 if ( is_wp_error( $job_id ) ) {
4508 return $job_id;
4509 }
4510 return array(
4511 'queued' => true,
4512 'job_id' => $job_id,
4513 'total' => count( $sites ),
4514 'errors' => $errors, // Include resolution + ACL errors in queued response.
4515 );
4516 }
4517
4518 $checked = array();
4519
4520 foreach ( $sites as $site ) {
4521 $result = MainWP_Monitoring_Handler::handle_check_website( $site );
4522
4523 if ( is_wp_error( $result ) ) {
4524 $errors[] = array(
4525 'identifier' => $site->url,
4526 'code' => 'mainwp_check_failed',
4527 'message' => $result->get_error_message(),
4528 );
4529 } elseif ( ! is_array( $result ) ) {
4530 $errors[] = array(
4531 'identifier' => $site->url,
4532 'code' => 'mainwp_check_failed',
4533 'message' => __( 'Unable to check site status.', 'mainwp' ),
4534 );
4535 } else {
4536 $http_code = isset( $result['httpCode'] ) ? (int) $result['httpCode'] : 0;
4537
4538 if ( isset( $result['new_uptime_status'] ) ) {
4539 $online = ( 1 === (int) $result['new_uptime_status'] );
4540 } else {
4541 $online = MainWP_Connect::check_ignored_http_code( $http_code, $site );
4542 }
4543
4544 $checked[] = array(
4545 'id' => (int) $site->id,
4546 'url' => $site->url,
4547 'name' => MainWP_Utility::remove_http_prefix( (string) $site->name, true ),
4548 'status' => array(
4549 'online' => (bool) $online,
4550 'http_code' => $http_code,
4551 ),
4552 );
4553 }
4554 }
4555
4556 return array(
4557 'checked' => $checked,
4558 'errors' => $errors,
4559 'total_checked' => count( $checked ),
4560 'total_errors' => count( $errors ),
4561 );
4562 }
4563
4564 /**
4565 * Register mainwp/suspend-sites-v1 ability.
4566 *
4567 * @return void
4568 */
4569 private static function register_suspend_sites(): void {
4570 wp_register_ability(
4571 'mainwp/suspend-sites-v1',
4572 array(
4573 'label' => __( 'Suspend MainWP Sites', 'mainwp' ),
4574 'description' => __( 'Suspend multiple MainWP child sites. Operations with >200 sites are automatically queued.', 'mainwp' ),
4575 'category' => 'mainwp-sites',
4576 'input_schema' => self::get_batch_sites_input_schema(),
4577 'output_schema' => self::get_batch_operation_output_schema(),
4578 'execute_callback' => array( self::class, 'execute_suspend_sites' ),
4579 'permission_callback' => array( MainWP_Abilities_Util::class, 'check_manage_sites_permission' ),
4580 'meta' => array(
4581 'show_in_rest' => true,
4582 'annotations' => array(
4583 'instructions' => 'Pass site_ids_or_domains with specific IDs, or empty array for all applicable sites. Operations with >200 sites are automatically queued for background processing.',
4584 'readonly' => false,
4585 'destructive' => false,
4586 'idempotent' => true,
4587 ),
4588 ),
4589 )
4590 );
4591 }
4592
4593 /**
4594 * Execute suspend-sites-v1 ability.
4595 *
4596 * @param array $input Input parameters.
4597 * @return array|\WP_Error Result or error.
4598 */
4599 public static function execute_suspend_sites( $input ) {
4600 $input = MainWP_Abilities_Util::normalize_input( $input );
4601
4602 // Core schema validation coerce-accepts scalars for type:array but hands the raw
4603 // value through; a non-array must not fall into the all-sites branch below.
4604 // array_key_exists, not isset: an explicit null is a present non-array too.
4605 if ( array_key_exists( 'site_ids_or_domains', $input ) && ! is_array( $input['site_ids_or_domains'] ) ) {
4606 return new \WP_Error(
4607 'mainwp_invalid_input',
4608 __( 'The site_ids_or_domains parameter must be an array of site IDs or domains.', 'mainwp' ),
4609 array( 'status' => 400 )
4610 );
4611 }
4612
4613 $identifiers = isset( $input['site_ids_or_domains'] ) ? $input['site_ids_or_domains'] : array();
4614
4615 // If empty, get all sites for current user.
4616 if ( empty( $identifiers ) ) {
4617 $all_sites = MainWP_DB::instance()->get_websites_for_current_user( array( 'selectgroups' => false ) );
4618
4619 // Handle potential errors from DB query.
4620 if ( is_wp_error( $all_sites ) ) {
4621 return $all_sites;
4622 }
4623
4624 $identifiers = array_map(
4625 function ( $s ) {
4626 return (int) $s->id;
4627 },
4628 $all_sites ? $all_sites : array()
4629 );
4630 }
4631
4632 // Resolve sites and enforce per-site ACLs.
4633 $access = MainWP_Abilities_Util::check_batch_site_access( $identifiers, $input );
4634 $sites = $access['allowed'];
4635 $errors = $access['denied']; // Resolution + access-denied errors.
4636
4637 if ( empty( $sites ) ) {
4638 return new \WP_Error(
4639 'mainwp_site_not_found',
4640 __( 'No valid sites found.', 'mainwp' ),
4641 array( 'status' => 404 )
4642 );
4643 }
4644
4645 $threshold = apply_filters( 'mainwp_abilities_batch_threshold', 200 );
4646
4647 if ( count( $sites ) > $threshold ) {
4648 $job_id = MainWP_Abilities_Util::queue_batch_operation( 'suspend', $sites );
4649 if ( is_wp_error( $job_id ) ) {
4650 return $job_id;
4651 }
4652 return array(
4653 'queued' => true,
4654 'job_id' => $job_id,
4655 'total' => count( $sites ),
4656 'errors' => $errors, // Include resolution + ACL errors in queued response.
4657 );
4658 }
4659
4660 $suspended = array();
4661
4662 foreach ( $sites as $site ) {
4663 MainWP_DB::instance()->update_website_values( $site->id, array( 'suspended' => 1 ) );
4664 do_action( 'mainwp_site_suspended', $site, 1 );
4665
4666 $suspended[] = array(
4667 'id' => (int) $site->id,
4668 'url' => $site->url,
4669 'name' => MainWP_Utility::remove_http_prefix( (string) $site->name, true ),
4670 'suspended' => 1,
4671 );
4672 }
4673
4674 return array(
4675 'suspended' => $suspended,
4676 'errors' => $errors,
4677 'total_suspended' => count( $suspended ),
4678 'total_errors' => count( $errors ),
4679 );
4680 }
4681 }
4682