PluginProbe
MainWP Dashboard: Self-hosted WordPress Management for Agencies / 6.2.1
MainWP Dashboard: Self-hosted WordPress Management for Agencies v6.2.1
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-util.php

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

1,330 lines 53.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MainWP Abilities Utilities
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_Util
16 *
17 * Shared utility functions for MainWP abilities.
18 */
19 class MainWP_Abilities_Util { //phpcs:ignore -- NOSONAR - multi methods.
20
21 /**
22 * Minimum child plugin version required for Abilities API operations.
23 *
24 * This constant centralizes the version requirement to ensure consistency
25 * across all ability executors and their corresponding tests.
26 *
27 * When bumping this version:
28 * 1. Update this constant
29 * 2. Update tests that verify the version check behavior
30 * 3. Consider backwards compatibility implications
31 *
32 * @since 5.4
33 * @var string
34 */
35 const MIN_CHILD_VERSION_FOR_ABILITIES = '4.0.0';
36
37 /**
38 * Check if current request has REST API permission.
39 *
40 * Prioritizes REST API key authentication; falls back to session-based
41 * authentication for legacy/admin use. This ensures compatibility with
42 * MainWP's consumer key/secret authentication which doesn't require a session.
43 *
44 * Note: Invalid or missing API key errors (e.g., 'mainwp_rest_authentication_error')
45 * are handled earlier in the REST authentication stack by MainWP_REST_Authentication
46 * via the 'rest_authentication_errors' filter. By the time this permission callback
47 * runs, invalid API requests have already been rejected. We only need to check if
48 * a valid REST user exists and verify their capabilities.
49 *
50 * IMPORTANT: Returns true on success or WP_Error on failure.
51 * This is required for abilities to surface proper error codes to consumers.
52 *
53 * @param mixed $_input The ability input (unused but required by signature).
54 * @return true|\WP_Error True on success, WP_Error with code on failure.
55 */
56 public static function check_rest_api_permission( $_input = null ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Required by callback signature NOSONAR .
57 // Prioritize MainWP REST API key authentication.
58 // Note: Key-specific errors (invalid key, invalid secret, bad signature) are
59 // handled by MainWP_REST_Authentication::check_authentication_error() which
60 // hooks into 'rest_authentication_errors' and blocks requests before this runs.
61 if ( class_exists( '\MainWP_REST_Authentication' ) ) {
62 $auth = \MainWP_REST_Authentication::get_instance();
63 $rest_user = $auth->get_rest_valid_user();
64
65 // If REST auth has validated a user, trust that authentication.
66 if ( ! empty( $rest_user ) ) {
67 // REST API key is valid, check capability of the associated user.
68 if ( ! current_user_can( 'manage_options' ) ) {
69 return new \WP_Error(
70 'mainwp_permission_denied',
71 __( 'API key user does not have sufficient permissions.', 'mainwp' ),
72 array( 'status' => 403 )
73 );
74 }
75 return true;
76 }
77 }
78
79 // Fallback: session-based authentication for admin/legacy use.
80 if ( ! is_user_logged_in() ) {
81 return new \WP_Error(
82 'mainwp_permission_denied',
83 __( 'You must be logged in to access this ability.', 'mainwp' ),
84 array( 'status' => 401 )
85 );
86 }
87
88 // Check for manage_options capability.
89 if ( ! current_user_can( 'manage_options' ) ) {
90 return new \WP_Error(
91 'mainwp_permission_denied',
92 __( 'You do not have permission to perform this action.', 'mainwp' ),
93 array( 'status' => 403 )
94 );
95 }
96
97 return true;
98 }
99
100 /**
101 * Check if current user can manage sites (write operations).
102 *
103 * Currently a wrapper over check_rest_api_permission() which already verifies
104 * manage_options capability. This method exists as an extension point for future
105 * granular permissions (e.g., a dedicated 'mainwp_manage_sites' capability).
106 *
107 * @param mixed $input The ability input (unused but required by signature).
108 * @return true|\WP_Error True on success, WP_Error on failure.
109 */
110 public static function check_manage_sites_permission( $input = null ) {
111 $base = self::check_rest_api_permission( $input );
112 if ( is_wp_error( $base ) ) {
113 return $base;
114 }
115
116 // Future: Add granular 'mainwp_manage_sites' capability check here.
117 // For now, manage_options is already verified in check_rest_api_permission().
118
119 return true;
120 }
121
122 /**
123 * Check if current user can access a specific site.
124 *
125 * This enforces per-site ACLs beyond the basic REST API permission check.
126 * Use this for all site-specific operations (get, sync, update, plugins, themes).
127 *
128 * IMPORTANT: Returns true on success or WP_Error on failure.
129 * For execute callbacks that need a boolean check, use the helper method below.
130 *
131 * @param int|object $site Site ID or site object.
132 * @param mixed $input The ability input (unused but required by signature).
133 * @return true|\WP_Error True on success, WP_Error on failure.
134 */
135 public static function check_site_access( $site, $input = null ) {
136 // First, verify basic REST API permission.
137 $base_check = self::check_rest_api_permission( $input );
138 if ( is_wp_error( $base_check ) ) {
139 return $base_check;
140 }
141
142 $site_id = is_object( $site ) ? (int) $site->id : (int) $site;
143
144 // Use MainWP's existing per-site access control.
145 if ( class_exists( 'MainWP_System' ) ) {
146 $system = MainWP_System::instance();
147 if ( method_exists( $system, 'check_site_access' ) && ! $system->check_site_access( $site_id ) ) {
148 return new \WP_Error(
149 'mainwp_access_denied',
150 __( 'You do not have permission to access this site.', 'mainwp' ),
151 array( 'status' => 403 )
152 );
153 }
154 }
155
156 /**
157 * Filters whether the current user can access a specific site.
158 *
159 * Allows plugins and tests to override or extend access control.
160 *
161 * @since 6.0.0
162 *
163 * @param bool $can_access Whether access is allowed (default true).
164 * @param int $site_id The site ID being checked.
165 * @param mixed|null $input The ability input, if available.
166 */
167 $can_access = apply_filters( 'mainwp_check_site_access', true, $site_id, $input );
168
169 if ( ! $can_access ) {
170 return new \WP_Error(
171 'mainwp_access_denied',
172 __( 'You do not have permission to access this site.', 'mainwp' ),
173 array( 'status' => 403 )
174 );
175 }
176
177 return true;
178 }
179
180 /**
181 * Check site access and return boolean.
182 *
183 * Convenience method for execute callbacks that need bool-style checks.
184 * Use check_site_access() for permission_callback (returns WP_Error for proper error responses).
185 *
186 * @param int|object $site Site ID or site object.
187 * @param mixed $input The ability input.
188 * @return bool True if access allowed, false otherwise.
189 */
190 public static function can_access_site( $site, $input = null ): bool {
191 return true === self::check_site_access( $site, $input );
192 }
193
194 /**
195 * Check if site's child plugin meets minimum version requirement.
196 *
197 * Use this in execute callbacks before performing operations that require
198 * child plugin communication. Returns WP_Error with 'mainwp_child_outdated'
199 * code if the child version is too low.
200 *
201 * @param object $site Site object with version property.
202 * @param string $min_version Minimum required version. Defaults to MIN_CHILD_VERSION_FOR_ABILITIES.
203 * Pass null or omit to use the class constant.
204 * @return true|\WP_Error True if version OK, WP_Error if outdated.
205 */
206 public static function check_child_version( $site, ?string $min_version = null ) {
207 // Use class constant as default when null or not provided.
208 if ( null === $min_version ) {
209 $min_version = self::MIN_CHILD_VERSION_FOR_ABILITIES;
210 }
211 // Validate site parameter.
212 if ( ! is_object( $site ) || ! isset( $site->id ) ) {
213 return new \WP_Error(
214 'mainwp_internal_error',
215 __( 'Invalid site object provided.', 'mainwp' ),
216 array( 'status' => 500 )
217 );
218 }
219
220 $child_version = $site->version ?? '0.0.0';
221 if ( version_compare( $child_version, $min_version, '<' ) ) {
222 return new \WP_Error(
223 'mainwp_child_outdated',
224 sprintf(
225 /* translators: %s: minimum version */
226 __( 'Child plugin version %s or higher required.', 'mainwp' ),
227 $min_version
228 ),
229 array(
230 'status' => 400,
231 'site_id' => (int) $site->id,
232 )
233 );
234 }
235 return true;
236 }
237
238 /**
239 * Check site access for batch operations.
240 *
241 * Filters a list of site identifiers to only those the user can access.
242 * Returns both accessible sites and access-denied errors.
243 *
244 * @param array $site_ids_or_domains Array of site IDs or URLs/domains.
245 * @param mixed $input The ability input (unused but required by signature).
246 * @return array Array with 'allowed' (accessible sites) and 'denied' (access errors).
247 */
248 public static function check_batch_site_access( array $site_ids_or_domains, $input = null ): array { // phpcs:ignore -- NOSONAR
249 $allowed = array();
250 $denied = array();
251 $exclude_ids = ( is_array( $input ) && ! empty( $input['exclude_ids'] ) && is_array( $input['exclude_ids'] ) )
252 ? array_map( 'absint', $input['exclude_ids'] )
253 : array();
254 $exclude_set = $exclude_ids ? array_fill_keys( $exclude_ids, true ) : array();
255
256 foreach ( $site_ids_or_domains as $identifier ) {
257 $site = self::resolve_site( $identifier );
258
259 if ( is_wp_error( $site ) ) {
260 $error_data = $site->get_error_data();
261 $denied[] = array(
262 'identifier' => $identifier,
263 'code' => $site->get_error_code(),
264 'message' => $site->get_error_message(),
265 'status' => isset( $error_data['status'] ) ? $error_data['status'] : null,
266 );
267 continue;
268 }
269
270 $access_check = self::check_site_access( $site, $input );
271 if ( is_wp_error( $access_check ) ) {
272 $error_data = $access_check->get_error_data();
273 $denied[] = array(
274 'identifier' => $identifier,
275 'code' => $access_check->get_error_code(),
276 'message' => $access_check->get_error_message(),
277 'status' => isset( $error_data['status'] ) ? $error_data['status'] : null,
278 );
279 continue;
280 }
281
282 $allowed[] = $site;
283 }
284
285 if ( ! empty( $exclude_set ) ) {
286 $filtered = array();
287 foreach ( $allowed as $entry ) {
288 $id = null;
289 if ( is_object( $entry ) && isset( $entry->id ) ) {
290 $id = (int) $entry->id;
291 } elseif ( is_array( $entry ) && isset( $entry['id'] ) ) {
292 $id = (int) $entry['id'];
293 } elseif ( is_numeric( $entry ) ) {
294 $id = (int) $entry;
295 }
296
297 if ( null !== $id && isset( $exclude_set[ $id ] ) ) {
298 continue;
299 }
300
301 $filtered[] = $entry;
302 }
303 $allowed = array_values( $filtered );
304 }
305
306 return array(
307 'allowed' => $allowed,
308 'denied' => $denied,
309 );
310 }
311
312 /**
313 * Resolve a site identifier to a MainWP site object.
314 *
315 * Resolution order:
316 * 1. If numeric → treat as MainWP site ID
317 * 2. Otherwise → treat as URL/domain and resolve
318 *
319 * @param int|string $site_id_or_domain Site ID or URL/domain.
320 * @return object|\WP_Error Site object on success, WP_Error on failure.
321 */
322 public static function resolve_site( $site_id_or_domain ) {
323 if ( ! class_exists( 'MainWP_DB' ) ) {
324 return new \WP_Error(
325 'mainwp_internal_error',
326 __( 'MainWP database class is not available.', 'mainwp' ),
327 array( 'status' => 500 )
328 );
329 }
330
331 $db = MainWP_DB::instance();
332
333 // Numeric → try as site ID.
334 if ( is_numeric( $site_id_or_domain ) ) {
335 $site = $db->get_website_by_id( (int) $site_id_or_domain );
336 if ( $site ) {
337 return $site;
338 }
339 return new \WP_Error(
340 'mainwp_site_not_found',
341 sprintf(
342 /* translators: %d: site ID */
343 __( 'No site found with ID %d.', 'mainwp' ),
344 (int) $site_id_or_domain
345 ),
346 array( 'status' => 404 )
347 );
348 }
349
350 // Non-numeric → treat as URL/domain.
351 $url = self::normalize_url( $site_id_or_domain );
352 $site = $db->get_websites_by_url( $url );
353
354 if ( $site && ! empty( $site ) ) {
355 return is_array( $site ) ? $site[0] : $site;
356 }
357
358 return new \WP_Error(
359 'mainwp_site_not_found',
360 sprintf(
361 /* translators: %s: domain or URL */
362 __( 'No site found matching "%s".', 'mainwp' ),
363 $site_id_or_domain
364 ),
365 array( 'status' => 404 )
366 );
367 }
368
369 /**
370 * Resolve a client identifier to a MainWP client object.
371 *
372 * Resolution order:
373 * 1. If numeric → treat as client ID
374 * 2. Otherwise → treat as email and resolve (exact match, returns first by ID)
375 *
376 * Note: Client emails are not enforced as unique in the database.
377 * Email lookup uses exact match and returns the first matching client
378 * ordered by client_id ASC (first created). For precise lookup,
379 * use numeric client ID.
380 *
381 * @param int|string $client_id_or_email Client ID or email address.
382 * @return object|\WP_Error Client object on success, WP_Error on failure.
383 */
384 public static function resolve_client( $client_id_or_email ) {
385 if ( ! class_exists( 'MainWP\Dashboard\MainWP_DB_Client' ) ) {
386 return new \WP_Error(
387 'mainwp_internal_error',
388 __( 'MainWP client database class is not available.', 'mainwp' ),
389 array( 'status' => 500 )
390 );
391 }
392
393 $db = MainWP_DB_Client::instance();
394
395 // Numeric → try as client ID.
396 if ( is_numeric( $client_id_or_email ) ) {
397 $client = $db->get_wp_client_by( 'client_id', (int) $client_id_or_email );
398 if ( $client ) {
399 return $client;
400 }
401 return new \WP_Error(
402 'mainwp_client_not_found',
403 sprintf(
404 /* translators: %d: client ID */
405 __( 'No client found with ID %d.', 'mainwp' ),
406 (int) $client_id_or_email
407 ),
408 array( 'status' => 404 )
409 );
410 }
411
412 // Non-numeric → treat as email.
413 // Use exact match on email with deterministic ordering.
414 global $wpdb;
415 $table = $wpdb->prefix . 'mainwp_wp_clients';
416 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Direct query needed for client lookup by email. Table name from $wpdb->prefix is safe.
417 $client = $wpdb->get_row(
418 $wpdb->prepare(
419 "SELECT * FROM {$table} WHERE client_email = %s ORDER BY client_id ASC LIMIT 1",
420 $client_id_or_email
421 )
422 );
423 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter
424
425 if ( $client ) {
426 return $client;
427 }
428
429 return new \WP_Error(
430 'mainwp_client_not_found',
431 sprintf(
432 /* translators: %s: email address */
433 __( 'No client found with email "%s".', 'mainwp' ),
434 $client_id_or_email
435 ),
436 array( 'status' => 404 )
437 );
438 }
439
440 /**
441 * Resolve a tag identifier to a tag object.
442 *
443 * @param int $tag_id Tag ID.
444 * @return object|\WP_Error Tag object on success, WP_Error on failure.
445 */
446 public static function resolve_tag( int $tag_id ) {
447 if ( ! class_exists( 'MainWP\Dashboard\MainWP_DB_Common' ) ) {
448 return new \WP_Error(
449 'mainwp_internal_error',
450 __( 'MainWP database class is not available.', 'mainwp' ),
451 array( 'status' => 500 )
452 );
453 }
454
455 $tag = MainWP_DB_Common::instance()->get_group_by_id( $tag_id );
456
457 if ( empty( $tag ) ) {
458 return new \WP_Error(
459 'mainwp_tag_not_found',
460 sprintf(
461 /* translators: %d: tag ID */
462 __( 'No tag found with ID %d.', 'mainwp' ),
463 $tag_id
464 ),
465 array( 'status' => 404 )
466 );
467 }
468
469 return $tag;
470 }
471
472 /**
473 * Map a client object or array to the standard output format.
474 *
475 * @param object|array $client Client object or array from database.
476 * @param array $options Optional formatting options.
477 * - 'include_sites' (bool): Include associated sites count.
478 * - 'include_contacts' (bool): Include client contacts.
479 * @return array Formatted client data.
480 */
481 public static function format_client_for_output( $client, array $options = array() ): array { // phpcs:ignore -- NOSONAR - complex method.
482 // Convert array to object for consistent access.
483 if ( is_array( $client ) ) {
484 $client = (object) $client;
485 }
486
487 $output = array(
488 'id' => (int) $client->client_id,
489 'name' => (string) $client->name,
490 'email' => isset( $client->client_email ) ? (string) $client->client_email : '',
491 'phone' => isset( $client->client_phone ) ? (string) $client->client_phone : '',
492 'address_1' => isset( $client->address_1 ) ? (string) $client->address_1 : '',
493 'address_2' => isset( $client->address_2 ) ? (string) $client->address_2 : '',
494 'city' => isset( $client->city ) ? (string) $client->city : '',
495 'state' => isset( $client->state ) ? (string) $client->state : '',
496 'zip' => isset( $client->zip ) ? (string) $client->zip : '',
497 'country' => isset( $client->country ) ? (string) $client->country : '',
498 'note' => isset( $client->note ) ? (string) $client->note : '',
499 'suspended' => isset( $client->suspended ) ? (int) $client->suspended : 0,
500 'created' => isset( $client->created ) && $client->created > 0
501 ? gmdate( 'c', (int) $client->created )
502 : null,
503 'facebook' => isset( $client->client_facebook ) ? (string) $client->client_facebook : '',
504 'twitter' => isset( $client->client_twitter ) ? (string) $client->client_twitter : '',
505 'instagram' => isset( $client->client_instagram ) ? (string) $client->client_instagram : '',
506 'linkedin' => isset( $client->client_linkedin ) ? (string) $client->client_linkedin : '',
507 );
508
509 // Include sites count if requested.
510 if ( ! empty( $options['include_sites'] ) ) {
511 $sites = MainWP_DB_Client::instance()->get_websites_by_client_ids( $client->client_id );
512 $output['sites_count'] = is_array( $sites ) ? count( $sites ) : 0;
513 }
514
515 return $output;
516 }
517
518 /**
519 * Map a cost tracker entry to the standard output format.
520 *
521 * @param object $cost Cost tracker object from database.
522 * @return array Formatted cost data.
523 */
524 public static function format_cost_for_output( $cost ): array {
525 return array(
526 'id' => (int) $cost->id,
527 'name' => (string) $cost->name,
528 'type' => isset( $cost->type ) ? (string) $cost->type : '',
529 'price' => isset( $cost->price ) ? (float) $cost->price : 0.0,
530 'renewal_type' => isset( $cost->renewal_type ) ? (string) $cost->renewal_type : '',
531 'payment_method' => isset( $cost->payment_method ) ? (string) $cost->payment_method : '',
532 'product_type' => isset( $cost->product_type ) ? (string) $cost->product_type : '',
533 'last_renewal' => isset( $cost->last_renewal ) && $cost->last_renewal > 0
534 ? gmdate( 'c', (int) $cost->last_renewal )
535 : null,
536 'next_renewal' => isset( $cost->next_renewal ) && $cost->next_renewal > 0
537 ? gmdate( 'c', (int) $cost->next_renewal )
538 : null,
539 );
540 }
541
542 /**
543 * Format a tag object for ability output.
544 *
545 * Standardizes tag data structure for consistent API responses.
546 *
547 * @param object $tag Tag object from MainWP_DB_Common::get_tags().
548 * @param array $options Optional. Formatting options:
549 * - 'include_sites_ids' (bool): Include array of site IDs. Default true.
550 * @return array Formatted tag data.
551 */
552 public static function format_tag_for_output( object $tag, array $options = array() ): array {
553 $defaults = array(
554 'include_sites_ids' => true,
555 );
556 $options = array_merge( $defaults, $options );
557
558 $formatted = array(
559 'id' => (int) $tag->id,
560 'name' => (string) $tag->name,
561 'color' => ! empty( $tag->color ) ? (string) $tag->color : null,
562 'sites_count' => isset( $tag->count_sites ) ? (int) $tag->count_sites : 0,
563 );
564
565 if ( $options['include_sites_ids'] ) {
566 // Always include sites_ids key to match output schema.
567 $formatted['sites_ids'] = array();
568
569 if ( isset( $tag->sites_ids ) ) {
570 if ( is_string( $tag->sites_ids ) && ! empty( $tag->sites_ids ) ) {
571 $formatted['sites_ids'] = array_map( 'intval', explode( ',', $tag->sites_ids ) );
572 } elseif ( is_array( $tag->sites_ids ) ) {
573 $formatted['sites_ids'] = array_map( 'intval', $tag->sites_ids );
574 }
575 }
576 }
577
578 return $formatted;
579 }
580
581 /**
582 * Resolve multiple site identifiers.
583 *
584 * @param array $site_ids_or_domains Array of site IDs or URLs/domains.
585 * @return array Array with 'sites' (resolved) and 'errors' (failed).
586 */
587 public static function resolve_sites( array $site_ids_or_domains ): array {
588 $sites = array();
589 $errors = array();
590
591 foreach ( $site_ids_or_domains as $identifier ) {
592 $site = self::resolve_site( $identifier );
593 if ( is_wp_error( $site ) ) {
594 $error_data = $site->get_error_data();
595 $errors[] = array(
596 'identifier' => $identifier,
597 'code' => $site->get_error_code(),
598 'message' => $site->get_error_message(),
599 'status' => isset( $error_data['status'] ) ? $error_data['status'] : null,
600 );
601 } else {
602 $sites[] = $site;
603 }
604 }
605
606 return array(
607 'sites' => $sites,
608 'errors' => $errors,
609 );
610 }
611
612 /**
613 * Resolve multiple site identifiers for batch operations.
614 *
615 * Convenience method that returns only successfully resolved sites,
616 * silently skipping any that fail to resolve. Use resolve_sites() if
617 * you need both resolved sites and error details.
618 *
619 * @param array $site_ids_or_domains Array of site IDs or URLs/domains.
620 * @return array Array of site objects (successfully resolved only).
621 */
622 public static function resolve_sites_batch( array $site_ids_or_domains ): array {
623 $result = self::resolve_sites( $site_ids_or_domains );
624 return $result['sites'];
625 }
626
627 /**
628 * Normalize a URL for site lookup.
629 *
630 * IMPORTANT: This normalization is for site resolution only.
631 *
632 * Handles:
633 * - Protocol stripping (https://, http://)
634 * - www prefix removal
635 * - Trailing slash enforcement
636 *
637 * LIMITATIONS (prefer site ID for these cases):
638 * - Port numbers (example.com:8080) - not stripped, may fail to match
639 * - Subdirectory multisites (example.com/site1 vs example.com/site2) - may collide
640 * - URL-encoded characters - not decoded
641 * - IDN/punycode domains - not normalized
642 *
643 * RECOMMENDATION: For ambiguous cases or programmatic access, always use
644 * site ID instead of URL/domain.
645 *
646 * @param string $url URL to normalize.
647 * @return string Normalized URL.
648 */
649 public static function normalize_url( string $url ): string {
650 // Remove protocol and www prefix.
651 $url = preg_replace( '#^https?://(www\.)?#i', '', $url );
652
653 // Ensure trailing slash.
654 $url = trailingslashit( $url );
655
656 return $url;
657 }
658
659 /**
660 * Normalize a site URL for storage and duplicate detection.
661 *
662 * Unlike normalize_url() which strips protocols for lookup, this method
663 * preserves the URL scheme while normalizing for consistent storage:
664 * - Lowercases the host component (domain names are case-insensitive per RFC 4343)
665 * - Ensures trailing slash on the path
666 * - Preserves the scheme (http/https)
667 *
668 * Use this before storing site URLs or checking for duplicates to ensure
669 * URLs like "HTTPS://EXAMPLE.COM" and "https://example.com/" are treated
670 * as equivalent.
671 *
672 * @param string $url URL to normalize.
673 * @return string Normalized URL with lowercase host and trailing slash.
674 */
675 public static function normalize_site_url( string $url ): string {
676 $parsed = wp_parse_url( $url );
677
678 if ( empty( $parsed ) || empty( $parsed['host'] ) ) {
679 // Invalid URL, return as-is with trailing slash.
680 return trailingslashit( $url );
681 }
682
683 // Build normalized URL.
684 $scheme = isset( $parsed['scheme'] ) ? strtolower( $parsed['scheme'] ) : 'https';
685 $host = strtolower( $parsed['host'] );
686 $port = isset( $parsed['port'] ) ? ':' . $parsed['port'] : '';
687 $path = isset( $parsed['path'] ) ? $parsed['path'] : '/';
688
689 // Ensure path has trailing slash (but not double slashes).
690 $path = trailingslashit( $path );
691
692 return $scheme . '://' . $host . $port . $path;
693 }
694
695 /**
696 * Create a permission callback for site-specific abilities.
697 *
698 * The Abilities API passes $input to the permission_callback, but our
699 * check_site_access() method expects a resolved site object. This wrapper
700 * resolves the site from input first, then performs the access check.
701 *
702 * IMPORTANT: Use this for any ability that operates on a single site
703 * identified by 'site_id_or_domain' in the input schema.
704 *
705 * @param string $input_key The input key containing the site identifier (default: 'site_id_or_domain').
706 * @return callable Permission callback closure.
707 */
708 public static function make_site_permission_callback( string $input_key = 'site_id_or_domain' ): callable {
709 return function ( $input ) use ( $input_key ) {
710 // First check basic REST API permission.
711 $base_check = self::check_rest_api_permission( $input );
712 if ( is_wp_error( $base_check ) ) {
713 return $base_check;
714 }
715
716 // Resolve the site from input.
717 $identifier = $input[ $input_key ] ?? null;
718 if ( null === $identifier ) {
719 return new \WP_Error(
720 'mainwp_invalid_input',
721 __( 'Site identifier is required.', 'mainwp' ),
722 array( 'status' => 400 )
723 );
724 }
725
726 $site = self::resolve_site( $identifier );
727 if ( is_wp_error( $site ) ) {
728 return $site;
729 }
730
731 // Check per-site access.
732 return self::check_site_access( $site, $input );
733 };
734 }
735
736 /**
737 * Map a site object to the standard output format.
738 *
739 * @param object $site Site object from database.
740 * @param bool $full_details Whether to include full site details (default: false).
741 * @param bool $include_stats Whether to include site statistics (default: false).
742 * @return array Formatted site data.
743 */
744 public static function format_site_for_output( $site, bool $full_details = false, bool $include_stats = false ): array {
745 $output = array(
746 'id' => (int) $site->id,
747 'url' => (string) $site->url,
748 'name' => MainWP_Utility::remove_http_prefix( (string) $site->name, true ),
749 'status' => self::get_site_status( $site ),
750 'client_id' => isset( $site->client_id ) && $site->client_id > 0
751 ? (int) $site->client_id
752 : null,
753 );
754
755 if ( $full_details ) {
756 // Add extended site details for single-site retrieval.
757 $output['admin_username'] = $site->adminname ?? '';
758
759 // Get site_info from DB option (stored as JSON).
760 // Pass full $site object to allow property check before DB query.
761 $site_info_raw = MainWP_DB::instance()->get_website_option( $site, 'site_info' );
762 $site_info = ! empty( $site_info_raw ) ? json_decode( $site_info_raw, true ) : array();
763 // Ensure $site_info is an array (json_decode returns null on invalid JSON).
764 if ( ! is_array( $site_info ) ) {
765 $site_info = array();
766 }
767
768 $output['wp_version'] = isset( $site_info['wpversion'] ) ? $site_info['wpversion'] : '';
769 $output['php_version'] = isset( $site_info['phpversion'] ) ? $site_info['phpversion'] : '';
770 $output['child_version'] = $site->version ?? '';
771
772 // Format last_sync as ISO 8601 timestamp.
773 $output['last_sync'] = ! empty( $site->dtsSync ) ? gmdate( 'c', (int) $site->dtsSync ) : null;
774
775 $output['notes'] = $site->note ?? '';
776 }
777
778 // Include site statistics if requested.
779 if ( $include_stats ) {
780 $output['stats'] = self::get_site_stats( $site );
781 }
782
783 return $output;
784 }
785
786 /**
787 * Get site statistics for include_stats option.
788 *
789 * @param object $site Site object from database.
790 * @return array Site statistics array.
791 */
792 public static function get_site_stats( $site ): array {
793 // Count plugin updates.
794 $plugin_updates = ! empty( $site->plugin_upgrades ) ? json_decode( $site->plugin_upgrades, true ) : array();
795 $plugin_count = is_array( $plugin_updates ) ? count( $plugin_updates ) : 0;
796
797 // Count theme updates.
798 $theme_updates = ! empty( $site->theme_upgrades ) ? json_decode( $site->theme_upgrades, true ) : array();
799 $theme_count = is_array( $theme_updates ) ? count( $theme_updates ) : 0;
800
801 // Check for WordPress core update.
802 // Pass full $site object to allow property check before DB query.
803 $wp_upgrades = MainWP_DB::instance()->get_website_option( $site, 'wp_upgrades' );
804 $wp_upgrades = ! empty( $wp_upgrades ) ? json_decode( $wp_upgrades, true ) : array();
805 $wp_update_available = is_array( $wp_upgrades ) && ! empty( $wp_upgrades );
806
807 // Get health score if available. The health_value column is a sortable
808 // composite (score - critical * 100) that goes negative when critical
809 // issues exist; recompute the real 0-100 score from the stored Site
810 // Health issue counts like the REST and UI surfaces do.
811 $health_score = null;
812 // json_format=true returns a guaranteed array (scalars coerced to array()).
813 $health_status = MainWP_DB::instance()->get_website_option( $site, 'health_site_status', array(), true );
814 if ( ! empty( $health_status ) ) {
815 $hstatus = MainWP_Utility::get_site_health( $health_status );
816 $health_score = (int) $hstatus['val'];
817 }
818
819 return array(
820 'plugin_updates' => $plugin_count,
821 'theme_updates' => $theme_count,
822 'wp_update_available' => $wp_update_available,
823 'health_score' => $health_score,
824 );
825 }
826
827 /**
828 * Get site connection status string.
829 *
830 * @param object $site Site object.
831 * @return string Status string: 'connected', 'disconnected', or 'suspended'.
832 */
833 public static function get_site_status( $site ): string {
834 // Check if site is suspended.
835 if ( isset( $site->suspended ) && 1 === (int) $site->suspended ) {
836 return 'suspended';
837 }
838
839 // Check offline status.
840 if ( isset( $site->offline_check_result ) && -1 === (int) $site->offline_check_result ) {
841 return 'disconnected';
842 }
843
844 // Check for sync errors.
845 if ( isset( $site->sync_errors ) && ! empty( $site->sync_errors ) ) {
846 return 'disconnected';
847 }
848
849 return 'connected';
850 }
851
852 /**
853 * Queue a batch sync operation for background processing.
854 *
855 * Used when >200 sites need to be synced to avoid request timeouts.
856 * Stores job data in a transient and schedules a cron event for processing.
857 *
858 * @param array $sites Array of site objects to sync.
859 * @return string|\WP_Error Job ID for status polling, or WP_Error on failure.
860 */
861 public static function queue_batch_sync( array $sites ) {
862 // Generate unique job ID.
863 $job_id = 'sync_' . wp_generate_uuid4();
864
865 // Extract site IDs from site objects.
866 $site_ids = array_map(
867 function ( $site ) {
868 return (int) $site->id;
869 },
870 $sites
871 );
872
873 // Store job data in transient (expires in 24 hours).
874 // Structure aligns with REST v2 job status endpoint expectations.
875 // Status values: 'queued' -> 'processing' -> 'completed' | 'failed'.
876 $job_data = array(
877 'job_type' => 'sync', // Distinguishes from 'update' jobs.
878 'sites' => $site_ids,
879 'status' => 'queued', // queued | processing | completed | failed.
880 'created' => time(),
881 'started' => null,
882 'completed' => null,
883 'synced' => array(), // Successfully synced sites.
884 'errors' => array(), // Failed syncs array.
885 'progress' => 0, // 0-100 percentage.
886 'total' => count( $site_ids ), // Total sites to process.
887 'processed' => 0, // Sites processed so far.
888 );
889
890 set_transient( 'mainwp_sync_job_' . $job_id, $job_data, DAY_IN_SECONDS );
891
892 // Verify transient was stored successfully.
893 $stored = get_transient( 'mainwp_sync_job_' . $job_id );
894 if ( empty( $stored ) || ! is_array( $stored ) ) {
895 return new \WP_Error(
896 'mainwp_queue_failed',
897 __( 'Failed to store sync job data. Please try again.', 'mainwp' ),
898 array( 'status' => 500 )
899 );
900 }
901
902 // Only schedule if no matching event already exists.
903 if ( ! wp_next_scheduled( 'mainwp_process_sync_job', array( $job_id ) ) ) {
904 wp_schedule_single_event( time() + 60, 'mainwp_process_sync_job', array( $job_id ) );
905 }
906
907 return $job_id;
908 }
909
910 /**
911 * Get batch sync job status.
912 *
913 * @param string $job_id Job ID to check.
914 * @return array|null Job data array or null if not found.
915 */
916 public static function get_batch_sync_status( string $job_id ): ?array {
917 $job_data = get_transient( 'mainwp_sync_job_' . $job_id );
918 return is_array( $job_data ) ? $job_data : null;
919 }
920
921 /**
922 * Queue a batch update operation for background processing.
923 *
924 * Used when >200 sites need updates to avoid request timeouts.
925 * Stores job data in a transient and schedules a cron event for processing.
926 *
927 * @param array $sites Array of site objects to update.
928 * @param array $update_params Update parameters with keys: types (array), specific_items (array).
929 * @return string|\WP_Error Job ID for status polling, or WP_Error on failure.
930 */
931 public static function queue_batch_updates( array $sites, array $update_params ) {
932 // Generate unique job ID.
933 $job_id = 'update_' . wp_generate_uuid4();
934
935 // Extract site IDs from site objects.
936 $site_ids = array_map(
937 function ( $site ) {
938 return (int) $site->id;
939 },
940 $sites
941 );
942
943 // Normalize and validate types parameter.
944 $allowed_types = array( 'core', 'plugins', 'themes', 'translations' );
945 $types = isset( $update_params['types'] ) && is_array( $update_params['types'] )
946 ? array_values( array_intersect( $update_params['types'], $allowed_types ) )
947 : array();
948
949 // Normalize specific_items to array of strings.
950 $specific_items = array();
951 if ( isset( $update_params['specific_items'] ) && is_array( $update_params['specific_items'] ) ) {
952 foreach ( $update_params['specific_items'] as $item ) {
953 if ( is_string( $item ) && '' !== $item ) {
954 $specific_items[] = $item;
955 }
956 }
957 }
958
959 // Store job data in transient (expires in 24 hours).
960 // Structure aligns with REST v2 job status endpoint expectations.
961 // Status values: 'queued' -> 'processing' -> 'completed' | 'failed'.
962 $job_data = array(
963 'job_type' => 'update', // Distinguishes from 'sync' jobs.
964 'sites' => $site_ids,
965 'types' => $types,
966 'specific_items' => $specific_items,
967 'status' => 'queued', // queued | processing | completed | failed.
968 'created' => time(),
969 'started' => null,
970 'completed' => null,
971 'updated' => array(), // Successful updates array.
972 'errors' => array(), // Failed updates array.
973 'progress' => 0, // 0-100 percentage.
974 'total' => count( $site_ids ), // Total sites to process.
975 'processed' => 0, // Sites processed so far.
976 );
977
978 set_transient( 'mainwp_update_job_' . $job_id, $job_data, DAY_IN_SECONDS );
979
980 // Verify transient was stored successfully.
981 $stored = get_transient( 'mainwp_update_job_' . $job_id );
982 if ( empty( $stored ) || ! is_array( $stored ) ) {
983 return new \WP_Error(
984 'mainwp_queue_failed',
985 __( 'Failed to store update job data. Please try again.', 'mainwp' ),
986 array( 'status' => 500 )
987 );
988 }
989
990 // Only schedule if no matching event already exists.
991 if ( ! wp_next_scheduled( 'mainwp_process_update_job', array( $job_id ) ) ) {
992 wp_schedule_single_event( time() + 60, 'mainwp_process_update_job', array( $job_id ) );
993 }
994
995 return $job_id;
996 }
997
998 /**
999 * Get batch update job status.
1000 *
1001 * @param string $job_id Job ID to check.
1002 * @return array|null Job data array or null if not found.
1003 */
1004 public static function get_batch_update_status( string $job_id ): ?array {
1005 $job_data = get_transient( 'mainwp_update_job_' . $job_id );
1006 return is_array( $job_data ) ? $job_data : null;
1007 }
1008
1009 /**
1010 * Queue a batch site operation for background processing.
1011 *
1012 * Used when >200 sites need to be processed for batch operations like
1013 * reconnect, disconnect, check, or suspend. Stores job data in a transient
1014 * and schedules a cron event for processing.
1015 *
1016 * @param string $operation_type Operation type: 'reconnect', 'disconnect', 'check', 'suspend'.
1017 * @param array $sites Array of site objects to process.
1018 * @return string|\WP_Error Job ID for status polling, or WP_Error on failure.
1019 */
1020 public static function queue_batch_operation( string $operation_type, array $sites ) {
1021 // Validate operation type.
1022 $allowed_operations = array( 'reconnect', 'disconnect', 'check', 'suspend' );
1023 if ( ! in_array( $operation_type, $allowed_operations, true ) ) {
1024 return new \WP_Error(
1025 'mainwp_invalid_operation',
1026 __( 'Invalid batch operation type.', 'mainwp' ),
1027 array( 'status' => 400 )
1028 );
1029 }
1030
1031 // Generate unique job ID.
1032 $job_id = $operation_type . '_' . wp_generate_uuid4();
1033
1034 // Extract site IDs from site objects.
1035 $site_ids = array_map(
1036 function ( $site ) {
1037 return (int) $site->id;
1038 },
1039 $sites
1040 );
1041
1042 // Store job data in transient (expires in 24 hours).
1043 // Status values: 'queued' -> 'processing' -> 'completed' | 'failed'.
1044 $job_data = array(
1045 'job_type' => $operation_type,
1046 'sites' => $site_ids,
1047 'status' => 'queued',
1048 'created' => time(),
1049 'started' => null,
1050 'completed' => null,
1051 'successful' => array(), // Successfully processed sites.
1052 'errors' => array(), // Failed operations array.
1053 'progress' => 0, // 0-100 percentage.
1054 'total' => count( $site_ids ),
1055 'processed' => 0, // Sites processed so far.
1056 );
1057
1058 set_transient( 'mainwp_batch_job_' . $job_id, $job_data, DAY_IN_SECONDS );
1059
1060 // Verify transient was stored successfully.
1061 $stored = get_transient( 'mainwp_batch_job_' . $job_id );
1062 if ( empty( $stored ) || ! is_array( $stored ) ) {
1063 return new \WP_Error(
1064 'mainwp_queue_failed',
1065 __( 'Failed to store batch job data. Please try again.', 'mainwp' ),
1066 array( 'status' => 500 )
1067 );
1068 }
1069
1070 // Schedule cron event for processing.
1071 // Only schedule if no matching event already exists.
1072 if ( ! wp_next_scheduled( 'mainwp_process_batch_job', array( $job_id ) ) ) {
1073 wp_schedule_single_event( time() + 60, 'mainwp_process_batch_job', array( $job_id ) );
1074 }
1075
1076 return $job_id;
1077 }
1078
1079 /**
1080 * Get batch operation job status.
1081 *
1082 * @param string $job_id Job ID to check.
1083 * @return array|null Job data array or null if not found.
1084 */
1085 public static function get_batch_operation_status( string $job_id ): ?array {
1086 $job_data = get_transient( 'mainwp_batch_job_' . $job_id );
1087 return is_array( $job_data ) ? $job_data : null;
1088 }
1089
1090 /**
1091 * Normalize ability input to ensure it's always an array.
1092 *
1093 * Workaround for Abilities API REST controller bug where missing or empty
1094 * input is passed as null instead of an empty array. The REST controller
1095 * fails JSON Schema validation before schema defaults can be applied.
1096 *
1097 * Use this at the start of execute callbacks as defensive coding:
1098 *
1099 * ```php
1100 * public static function execute_list_sites( $input ) {
1101 * $input = MainWP_Abilities_Util::normalize_input( $input, [
1102 * 'page' => 1,
1103 * 'per_page' => 20,
1104 * ] );
1105 * // ... continue with normalized input
1106 * }
1107 * ```
1108 *
1109 * NOTE: This workaround only helps if the ability passes REST validation.
1110 * The bug causes validation to fail BEFORE the execute callback is called.
1111 * This helper is provided for defensive coding once the upstream bug is fixed.
1112 *
1113 * @see .mwpdev/debugging/abilities-api-input-investigation.md
1114 * @see .mwpdev/docs/abilities-api-docs/known-issues.md
1115 *
1116 * @param mixed $input Raw input from ability execution (may be null, array, or object).
1117 * @param array $defaults Default values from input schema properties.
1118 * @return array Normalized input array with defaults applied.
1119 */
1120 public static function normalize_input( $input, array $defaults = array() ): array {
1121 // Convert objects to arrays.
1122 if ( is_object( $input ) ) {
1123 $input = (array) $input;
1124 }
1125
1126 // If input is null, empty, or not an array, start with empty array.
1127 if ( ! is_array( $input ) ) {
1128 $input = array();
1129 }
1130
1131 // Merge with defaults (input values take precedence over defaults).
1132 return array_merge( $defaults, $input );
1133 }
1134
1135 /**
1136 * Get default values from an input schema.
1137 *
1138 * Extracts default values from a JSON Schema's properties definitions.
1139 * Useful when you need to programmatically obtain defaults without hardcoding.
1140 *
1141 * Example:
1142 * ```php
1143 * $schema = self::get_list_sites_input_schema();
1144 * $defaults = MainWP_Abilities_Util::get_schema_defaults( $schema );
1145 * $input = MainWP_Abilities_Util::normalize_input( $input, $defaults );
1146 * ```
1147 *
1148 * @param array $schema JSON Schema with 'properties' containing 'default' values.
1149 * @return array Associative array of property names to their default values.
1150 */
1151 public static function get_schema_defaults( array $schema ): array {
1152 $defaults = array();
1153
1154 if ( ! isset( $schema['properties'] ) || ! is_array( $schema['properties'] ) ) {
1155 return $defaults;
1156 }
1157
1158 foreach ( $schema['properties'] as $property_name => $property_schema ) {
1159 if ( isset( $property_schema['default'] ) ) {
1160 $defaults[ $property_name ] = $property_schema['default'];
1161 }
1162 }
1163
1164 return $defaults;
1165 }
1166
1167 /**
1168 * Format plugin data for ability output.
1169 *
1170 * Standardizes plugin data structure for activate/deactivate/delete operations.
1171 * Handles missing fields gracefully as plugins may not have all metadata.
1172 *
1173 * @param array|object $plugin Plugin data with keys: slug, Name/name, Version/version, active, update.
1174 * @return array Standardized plugin structure: { slug, name, version, active, has_update, update_version }.
1175 */
1176 public static function format_plugin_for_output( $plugin ): array { // phpcs:ignore -- NOSONAR -complexity.
1177 if ( is_object( $plugin ) ) {
1178 $plugin = (array) $plugin;
1179 }
1180
1181 if ( ! is_array( $plugin ) ) {
1182 $plugin = array();
1183 }
1184
1185 $slug = isset( $plugin['slug'] ) ? sanitize_text_field( $plugin['slug'] ) : '';
1186
1187 // Support both capitalized (Name) and lowercase (name) keys.
1188 $name = '';
1189 if ( isset( $plugin['Name'] ) ) {
1190 $name = sanitize_text_field( $plugin['Name'] );
1191 } elseif ( isset( $plugin['name'] ) ) {
1192 $name = sanitize_text_field( $plugin['name'] );
1193 } else {
1194 $name = $slug;
1195 }
1196
1197 // Support both capitalized (Version) and lowercase (version) keys.
1198 $version = '';
1199 if ( isset( $plugin['Version'] ) ) {
1200 $version = sanitize_text_field( $plugin['Version'] );
1201 } elseif ( isset( $plugin['version'] ) ) {
1202 $version = sanitize_text_field( $plugin['version'] );
1203 }
1204
1205 $active = isset( $plugin['active'] ) ? (bool) $plugin['active'] : false;
1206
1207 // Determine if update is available: either a non-empty array in 'update' key,
1208 // or a root-level 'new_version' key indicates an update is available.
1209 $has_update_array = isset( $plugin['update'] ) && is_array( $plugin['update'] ) && ! empty( $plugin['update'] );
1210 $has_root_version = isset( $plugin['new_version'] );
1211 $has_update = $has_update_array || $has_root_version;
1212
1213 // Extract update_version from various possible structures.
1214 // Guard nested access with is_array() to avoid warnings when 'update' is boolean/scalar.
1215 $update_version = null;
1216 if ( $has_update ) {
1217 if ( $has_update_array && isset( $plugin['update']['new_version'] ) ) {
1218 $update_version = sanitize_text_field( $plugin['update']['new_version'] );
1219 } elseif ( $has_root_version ) {
1220 $update_version = sanitize_text_field( $plugin['new_version'] );
1221 }
1222 }
1223
1224 return array(
1225 'slug' => $slug,
1226 'name' => $name,
1227 'version' => $version,
1228 'active' => $active,
1229 'has_update' => $has_update,
1230 'update_version' => $update_version,
1231 );
1232 }
1233
1234 /**
1235 * Format theme data for ability output.
1236 *
1237 * Standardizes theme data structure for activate/delete operations.
1238 * Handles missing fields gracefully as themes may not have all metadata.
1239 *
1240 * @param array|object $theme Theme data with keys: slug, Name/name, Version/version, active, update.
1241 * @return array Standardized theme structure: { slug, name, version, active, has_update, update_version }.
1242 */
1243 public static function format_theme_for_output( $theme ): array { // phpcs:ignore -- NOSONAR -complexity.
1244 if ( is_object( $theme ) ) {
1245 $theme = (array) $theme;
1246 }
1247
1248 if ( ! is_array( $theme ) ) {
1249 $theme = array();
1250 }
1251
1252 $slug = isset( $theme['slug'] ) ? sanitize_text_field( $theme['slug'] ) : '';
1253
1254 // Support both capitalized (Name) and lowercase (name) keys.
1255 $name = '';
1256 if ( isset( $theme['Name'] ) ) {
1257 $name = sanitize_text_field( $theme['Name'] );
1258 } elseif ( isset( $theme['name'] ) ) {
1259 $name = sanitize_text_field( $theme['name'] );
1260 } else {
1261 $name = $slug;
1262 }
1263
1264 // Support both capitalized (Version) and lowercase (version) keys.
1265 $version = '';
1266 if ( isset( $theme['Version'] ) ) {
1267 $version = sanitize_text_field( $theme['Version'] );
1268 } elseif ( isset( $theme['version'] ) ) {
1269 $version = sanitize_text_field( $theme['version'] );
1270 }
1271
1272 $active = isset( $theme['active'] ) ? (bool) $theme['active'] : false;
1273
1274 // Determine if update is available: either a non-empty array in 'update' key,
1275 // or a root-level 'new_version' key indicates an update is available.
1276 $has_update_array = isset( $theme['update'] ) && is_array( $theme['update'] ) && ! empty( $theme['update'] );
1277 $has_root_version = isset( $theme['new_version'] );
1278 $has_update = $has_update_array || $has_root_version;
1279
1280 // Extract update_version from various possible structures.
1281 // Guard nested access with is_array() to avoid warnings when 'update' is boolean/scalar.
1282 $update_version = null;
1283 if ( $has_update ) {
1284 if ( $has_update_array && isset( $theme['update']['new_version'] ) ) {
1285 $update_version = sanitize_text_field( $theme['update']['new_version'] );
1286 } elseif ( $has_root_version ) {
1287 $update_version = sanitize_text_field( $theme['new_version'] );
1288 }
1289 }
1290
1291 return array(
1292 'slug' => $slug,
1293 'name' => $name,
1294 'version' => $version,
1295 'active' => $active,
1296 'has_update' => $has_update,
1297 'update_version' => $update_version,
1298 );
1299 }
1300
1301 /**
1302 * Check if a child site response indicates success.
1303 *
1304 * Handles both the new format ({ success: true }) and legacy format ({ status: "SUCCESS" })
1305 * returned by MainWP Child plugin for plugin/theme actions.
1306 *
1307 * @since 5.4
1308 *
1309 * @param mixed $result The response from MainWP_Connect::fetch_url_authed().
1310 * @return bool True if the response indicates success, false otherwise.
1311 */
1312 public static function is_child_response_success( $result ): bool {
1313 if ( ! is_array( $result ) ) {
1314 return false;
1315 }
1316
1317 // New format: { success: true }.
1318 if ( isset( $result['success'] ) && $result['success'] ) {
1319 return true;
1320 }
1321
1322 // Legacy format: { status: "SUCCESS" }.
1323 if ( isset( $result['status'] ) && 'SUCCESS' === $result['status'] ) {
1324 return true;
1325 }
1326
1327 return false;
1328 }
1329 }
1330