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 / rest-api / controller / version2 / class-mainwp-rest-sites-controller.php

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

3,418 lines 139.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MainWP REST Controller
4 *
5 * This class handles the REST API
6 *
7 * @package MainWP\Dashboard
8 */
9
10 use MainWP\Dashboard\MainWP_Sync;
11 use MainWP\Dashboard\MainWP_DB;
12 use MainWP\Dashboard\MainWP_Manage_Sites_View;
13 use MainWP\Dashboard\MainWP_Manage_Sites_Handler;
14 use MainWP\Dashboard\MainWP_Connect;
15 use MainWP\Dashboard\MainWP_Monitoring_Handler;
16 use MainWP\Dashboard\MainWP_Extra_Exception;
17 use MainWP\Dashboard\MainWP_DB_Client;
18 use MainWP\Dashboard\Module\CostTracker\Cost_Tracker_DB;
19 use MainWP\Dashboard\Module\CostTracker\Cost_Tracker_Rest_Api_Handle_V1;
20 use MainWP\Dashboard\MainWP_DB_Common;
21 use MainWP\Dashboard\MainWP_Utility;
22 use MainWP\Dashboard\MainWP_DB_Site_Actions;
23 use MainWP\Dashboard\MainWP_Logger;
24
25 // Exit if accessed directly.
26 if ( ! defined( 'ABSPATH' ) ) {
27 exit;
28 }
29
30
31 /**
32 * Class MainWP_Rest_Sites_Controller
33 *
34 * @package MainWP\Dashboard
35 */
36 class MainWP_Rest_Sites_Controller extends MainWP_REST_Controller{ //phpcs:ignore -- NOSONAR - multi methods.
37
38 // phpcs:disable Generic.Metrics.CyclomaticComplexity -- complexity.
39
40 /**
41 * Protected static variable to hold the single instance of the class.
42 *
43 * @var mixed Default null
44 */
45 private static $instance = null;
46
47 /**
48 * Endpoint namespace.
49 *
50 * @var string
51 */
52 protected $namespace = 'mainwp/v2';
53
54 /**
55 * Route base.
56 *
57 * @var string
58 */
59 protected $rest_base = 'sites';
60
61
62 /**
63 * Constructor.
64 */
65 public function __construct() {
66 add_filter( 'mainwp_rest_routes_sites_controller_filter_allowed_fields_by_context', array( $this, 'hook_filter_allowed_fields_by_context' ), 10, 3 );
67 add_filter( 'mainwp_rest_routes_sites_controller_get_allowed_fields_by_context', array( $this, 'hook_get_allowed_fields_by_context' ), 10, 1 );
68 add_filter( 'rest_request_after_callbacks', array( $this, 'hook_preserve_enveloped_fields_response' ), 10, 3 );
69 }
70
71 /**
72 * Method instance()
73 *
74 * Create public static instance.
75 *
76 * @static
77 * @return static::$instance
78 */
79 public static function instance() {
80 if ( null === static::$instance ) {
81 static::$instance = new self();
82 }
83 return static::$instance;
84 }
85
86
87 /**
88 * Get filter sites.
89 *
90 * @param object $item item data.
91 * @param string $context context.
92 * @param mixed $request The rest request.
93 *
94 * @return object item in context.
95 */
96 public function hook_filter_allowed_fields_by_context( $item, $context = 'view', $request = array() ) {
97 return $this->prepare_site_item_for_response_context( $item, $request, $context );
98 }
99
100 /**
101 * Get filter sites.
102 *
103 * @param string $context context.
104 *
105 * @return object item in context.
106 */
107 public function hook_get_allowed_fields_by_context( $context = 'view' ) {
108 return $this->get_allowed_fields_by_context( $context );
109 }
110
111 /**
112 * Site fields that must never appear in any v2 response, regardless of
113 * context or _fields. The schema declares http_pass / http_user / uniqueId
114 * as edit-only (MWP-1541), but check_permissions() in the auth layer only
115 * distinguishes GET vs write methods, so a read-only key can still request
116 * `?context=edit`. The _fields short-circuit below also skips the schema
117 * filter entirely. This list provides belt-and-suspenders defence so a
118 * read-only caller cannot recover credential fields via either bypass.
119 *
120 * @var string[]
121 */
122 protected static $never_in_response_fields = array(
123 'privkey',
124 'pubkey',
125 'http_user',
126 'http_pass',
127 'adminname',
128 'securekey',
129 'uniqueId',
130 // Projected onto the row for the health_score fast-path (not a schema field);
131 // reserve it so ?custom_fields=health_site_status cannot surface the raw value.
132 'health_site_status',
133 );
134
135 /**
136 * Strip credential-shaped fields from a prepared response item in place.
137 * Operates on either an associative array or an object.
138 *
139 * @param mixed $prepared Prepared item (array or object).
140 * @return mixed The same item with credential fields removed.
141 */
142 private function strip_never_in_response_fields( $prepared ) {
143 if ( is_array( $prepared ) ) {
144 foreach ( static::$never_in_response_fields as $field ) {
145 unset( $prepared[ $field ] );
146 }
147 } elseif ( is_object( $prepared ) ) {
148 foreach ( static::$never_in_response_fields as $field ) {
149 unset( $prepared->{$field} );
150 }
151 }
152 return $prepared;
153 }
154
155 /**
156 * Prepare a site response item while preserving `_fields` projections.
157 *
158 * `prepare_item_for_response()` already narrows the payload when `_fields`
159 * is present. Running the result back through
160 * `filter_response_data_by_allowed_fields()` expands it again to all schema
161 * fields with empty-string placeholders. This helper avoids that behavior.
162 *
163 * @param object $item Site item.
164 * @param WP_REST_Request $request Request object.
165 * @param string $context Response context.
166 * @param array $addition_fields Extra fields to include beyond the schema projection.
167 *
168 * @return array
169 */
170 protected function prepare_site_item_for_response_context( $item, $request, $context = 'view', $addition_fields = array() ) {
171 $prepared = $this->prepare_item_for_response( $item, $request );
172
173 if ( ! ( $request instanceof WP_REST_Request && ! empty( $request['_fields'] ) ) ) {
174 $prepared = $this->filter_response_data_by_allowed_fields( $prepared, $context, $addition_fields );
175 }
176
177 // MWP-1541: strip credential fields unconditionally on read.
178 //
179 // The v2 schema labels http_pass / http_user / uniqueId as edit-only,
180 // but every site-response code path in this controller passes the
181 // context arg as 'view' (or 'simple_view') -- there is no caller that
182 // forwards the request's `?context=edit` query through to the
183 // schema-filter step, so the edit-context response shape is
184 // effectively unreachable from the v2 sites routes today. That
185 // makes this strip equivalent to "strip on every read response",
186 // which is what we want for the security fix anyway: REST consumers
187 // never need to read these fields back, and writes accept them via
188 // the request body regardless.
189 //
190 // Re-enabling edit-context reads in the future would mean plumbing
191 // the requested context through the existing call sites and then
192 // narrowing this strip to view only. Out of scope for MWP-1541.
193 return $this->strip_never_in_response_fields( $prepared );
194 }
195
196 /**
197 * Preserve MainWP's custom response envelopes when `_fields` is used.
198 *
199 * WordPress core applies `_fields` to the top-level response object. MainWP's
200 * sites routes return custom envelopes such as {success,total,data}, so core
201 * would otherwise strip the envelope and serialize an empty array for requests
202 * like `_fields=id`. The controller already applies `_fields` to each site
203 * item via prepare_item_for_response(), so we clear the request parameter after
204 * callbacks for enveloped sites responses only.
205 *
206 * @param mixed $response Response to replace the requested version with.
207 * @param array $handler Route handler used for the request.
208 * @param WP_REST_Request $request Request used to generate the response.
209 *
210 * @return mixed
211 */
212 public function hook_preserve_enveloped_fields_response( $response, $handler, $request ) { // phpcs:ignore -- NOSONAR - hook signature.
213 if ( empty( $request['_fields'] ) ) {
214 return $response;
215 }
216
217 $route_prefix = '/' . $this->namespace . '/' . $this->rest_base;
218 $route = $request->get_route();
219 if ( 0 !== strpos( $route, $route_prefix ) || is_wp_error( $response ) ) {
220 return $response;
221 }
222
223 $response = rest_ensure_response( $response );
224 $data = $response->get_data();
225
226 if ( ! is_array( $data ) || ( ! array_key_exists( 'data', $data ) && ! array_key_exists( 'site', $data ) ) ) {
227 return $response;
228 }
229
230 $request->set_param( '_fields', null );
231
232 return $response;
233 }
234
235 /**
236 * Method register_routes()
237 *
238 * Creates the necessary endpoints for the api.
239 * Note, for a request to be successful the URL query parameters consumer_key and consumer_secret need to be set and correct.
240 */
241 public function register_routes() { // phpcs:ignore -- NOSONAR - complex.
242
243 register_rest_route(
244 $this->namespace,
245 '/' . $this->rest_base,
246 array(
247 array(
248 'methods' => WP_REST_Server::READABLE,
249 'callback' => array( $this, 'get_items' ),
250 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
251 ),
252 )
253 );
254
255 register_rest_route(
256 $this->namespace,
257 '/' . $this->rest_base . '/basic',
258 array(
259 array(
260 'methods' => WP_REST_Server::READABLE,
261 'callback' => array( $this, 'get_basic_items' ),
262 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
263 ),
264 )
265 );
266
267 register_rest_route(
268 $this->namespace,
269 '/' . $this->rest_base . '/count',
270 array(
271 array(
272 'methods' => WP_REST_Server::READABLE,
273 'callback' => array( $this, 'count_items' ),
274 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
275 ),
276 )
277 );
278
279 register_rest_route(
280 $this->namespace,
281 '/' . $this->rest_base . '/sync',
282 array(
283 array(
284 'methods' => WP_REST_Server::EDITABLE,
285 'callback' => array( $this, 'sync_items' ),
286 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
287 ),
288 )
289 );
290
291 register_rest_route(
292 $this->namespace,
293 '/' . $this->rest_base . '/reconnect',
294 array(
295 array(
296 'methods' => WP_REST_Server::EDITABLE,
297 'callback' => array( $this, 'reconnect_items' ),
298 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
299 ),
300 )
301 );
302
303 register_rest_route(
304 $this->namespace,
305 '/' . $this->rest_base . '/disconnect',
306 array(
307 array(
308 'methods' => WP_REST_Server::EDITABLE,
309 'callback' => array( $this, 'disconnect_sites' ),
310 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
311 ),
312 )
313 );
314
315 register_rest_route(
316 $this->namespace,
317 '/' . $this->rest_base . '/check',
318 array(
319 array(
320 'methods' => WP_REST_Server::EDITABLE,
321 'callback' => array( $this, 'check_items' ),
322 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
323 ),
324 )
325 );
326
327 register_rest_route(
328 $this->namespace,
329 '/' . $this->rest_base . '/(?P<id_domain>[\d]+)',
330 array(
331 array(
332 'methods' => WP_REST_Server::READABLE,
333 'callback' => array( $this, 'get_item' ),
334 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
335 'args' => array(
336 'id_domain' => array(
337 'description' => __( 'Site ID or domain.', 'mainwp' ),
338 'type' => 'string',
339 ),
340 ),
341 ),
342 'schema' => array( $this, 'get_public_item_schema' ),
343 )
344 );
345
346 register_rest_route(
347 $this->namespace,
348 '/' . $this->rest_base . '/(?P<id_domain>[A-Za-z0-9-\.]*[A-Za-z0-9-]{1,63}\.[A-Za-z]{2,6}$)',
349 array(
350 array(
351 'methods' => WP_REST_Server::READABLE,
352 'callback' => array( $this, 'get_item' ),
353 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
354 'args' => array(
355 'id_domain' => array(
356 'description' => __( 'Site ID or domain.', 'mainwp' ),
357 'type' => 'string',
358 ),
359 ),
360 ),
361 'schema' => array( $this, 'get_public_item_schema' ),
362 )
363 );
364
365 register_rest_route(
366 $this->namespace,
367 '/' . $this->rest_base . '/add',
368 array(
369 array(
370 'methods' => WP_REST_Server::CREATABLE,
371 'callback' => array( $this, 'create_item' ),
372 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
373 'args' => $this->get_add_site_args(),
374 ),
375 )
376 );
377
378 register_rest_route(
379 $this->namespace,
380 '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/edit',
381 array(
382 array(
383 'methods' => WP_REST_Server::EDITABLE,
384 'callback' => array( $this, 'update_item' ),
385 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
386 'args' => $this->get_edit_site_args(),
387 ),
388 )
389 );
390
391 register_rest_route(
392 $this->namespace,
393 '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/sync',
394 array(
395 array(
396 'methods' => WP_REST_Server::EDITABLE,
397 'callback' => array( $this, 'sync_item' ),
398 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
399 ),
400 )
401 );
402
403 register_rest_route(
404 $this->namespace,
405 '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/security',
406 array(
407 array(
408 'methods' => WP_REST_Server::READABLE,
409 'callback' => array( $this, 'security_item' ),
410 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
411 ),
412 )
413 );
414
415 register_rest_route(
416 $this->namespace,
417 '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/plugins',
418 array(
419 array(
420 'methods' => WP_REST_Server::READABLE,
421 'callback' => array( $this, 'get_site_plugins' ),
422 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
423 ),
424 )
425 );
426
427 register_rest_route(
428 $this->namespace,
429 '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/plugins/activate',
430 array(
431 array(
432 'methods' => WP_REST_Server::EDITABLE,
433 'callback' => array( $this, 'activate_plugins_of_site' ),
434 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
435 ),
436 )
437 );
438
439 register_rest_route(
440 $this->namespace,
441 '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/plugins/deactivate',
442 array(
443 array(
444 'methods' => WP_REST_Server::EDITABLE,
445 'callback' => array( $this, 'deactivate_plugins_of_site' ),
446 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
447 ),
448 )
449 );
450
451 register_rest_route(
452 $this->namespace,
453 '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/plugins/delete',
454 array(
455 array(
456 'methods' => WP_REST_Server::DELETABLE,
457 'callback' => array( $this, 'delete_plugins_of_site' ),
458 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
459 'args' => $this->get_collection_params(),
460 ),
461 )
462 );
463
464 register_rest_route(
465 $this->namespace,
466 '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/plugins/abandoned',
467 array(
468 array(
469 'methods' => WP_REST_Server::READABLE,
470 'callback' => array( $this, 'get_abandoned_plugins_of_site' ),
471 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
472 'args' => $this->get_collection_params(),
473 ),
474 )
475 );
476
477 register_rest_route(
478 $this->namespace,
479 '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/themes',
480 array(
481 array(
482 'methods' => WP_REST_Server::READABLE,
483 'callback' => array( $this, 'get_site_themes' ),
484 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
485 ),
486 )
487 );
488
489 register_rest_route(
490 $this->namespace,
491 '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/themes/activate',
492 array(
493 array(
494 'methods' => WP_REST_Server::EDITABLE,
495 'callback' => array( $this, 'activate_themes_of_site' ),
496 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
497 ),
498 )
499 );
500
501 register_rest_route(
502 $this->namespace,
503 '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/themes/delete',
504 array(
505 array(
506 'methods' => WP_REST_Server::DELETABLE,
507 'callback' => array( $this, 'delete_themes_of_site' ),
508 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
509 ),
510 )
511 );
512
513 register_rest_route(
514 $this->namespace,
515 '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/themes/abandoned',
516 array(
517 array(
518 'methods' => WP_REST_Server::READABLE,
519 'callback' => array( $this, 'get_abandoned_themes_of_site' ),
520 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
521 'args' => $this->get_collection_params(),
522 ),
523 )
524 );
525
526 register_rest_route(
527 $this->namespace,
528 '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/non-mainwp-changes',
529 array(
530 array(
531 'methods' => WP_REST_Server::READABLE,
532 'callback' => array( $this, 'get_non_mainwp_changes_of_site' ),
533 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
534 'args' => $this->get_collection_params(),
535 ),
536 )
537 );
538
539 register_rest_route(
540 $this->namespace,
541 '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/reconnect',
542 array(
543 array(
544 'methods' => WP_REST_Server::EDITABLE,
545 'callback' => array( $this, 'reconnect_item' ),
546 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
547 'args' => $this->get_collection_params(),
548 ),
549 )
550 );
551
552 register_rest_route(
553 $this->namespace,
554 '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/disconnect',
555 array(
556 array(
557 'methods' => WP_REST_Server::EDITABLE,
558 'callback' => array( $this, 'disconnect_site' ),
559 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
560 'args' => $this->get_collection_params(),
561 ),
562 )
563 );
564
565 register_rest_route(
566 $this->namespace,
567 '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/suspend',
568 array(
569 array(
570 'methods' => WP_REST_Server::EDITABLE,
571 'callback' => array( $this, 'suspend_item' ),
572 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
573 'args' => $this->get_collection_params(),
574 ),
575 )
576 );
577
578 register_rest_route(
579 $this->namespace,
580 '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/unsuspend',
581 array(
582 array(
583 'methods' => WP_REST_Server::EDITABLE,
584 'callback' => array( $this, 'unsuspend_item' ),
585 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
586 'args' => $this->get_collection_params(),
587 ),
588 )
589 );
590
591 register_rest_route(
592 $this->namespace,
593 '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/check',
594 array(
595 array(
596 'methods' => WP_REST_Server::EDITABLE,
597 'callback' => array( $this, 'check_item' ),
598 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
599 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
600 ),
601 )
602 );
603
604 register_rest_route(
605 $this->namespace,
606 '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/remove',
607 array(
608 array(
609 'methods' => WP_REST_Server::DELETABLE,
610 'callback' => array( $this, 'delete_item' ),
611 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
612 'args' => $this->get_collection_params(),
613 ),
614 )
615 );
616
617 // Retrieves client Object for the site by site ID or Domain.
618 register_rest_route(
619 $this->namespace,
620 '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/client',
621 array(
622 array(
623 'methods' => WP_REST_Server::READABLE,
624 'callback' => array( $this, 'get_client_site' ),
625 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
626 'args' => $this->get_collection_params(),
627 ),
628 )
629 );
630
631 // Retrieves costs for the site by ID or Domain.
632 register_rest_route(
633 $this->namespace,
634 '/' . $this->rest_base . '/(?P<id_domain>[a-zA-Z0-9\-\.\_]+)/costs',
635 array(
636 array(
637 'methods' => WP_REST_Server::READABLE,
638 'callback' => array( $this, 'get_costs_site' ),
639 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
640 'args' => $this->get_collection_params(),
641 ),
642 )
643 );
644
645 register_rest_route(
646 $this->namespace,
647 '/' . $this->rest_base . '/batch',
648 array(
649 array(
650 'methods' => WP_REST_Server::EDITABLE,
651 'callback' => array( $this, 'batch_items' ),
652 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
653 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
654 ),
655 'schema' => array( $this, 'get_public_batch_schema' ),
656 )
657 );
658 }
659
660
661 /**
662 * Read the ?custom_fields argument as a list of wp_options names.
663 *
664 * These names travel into extra_view and reach the option-view builders in
665 * identifier position (column aliases), where escaping is not a defense, so
666 * anything outside [A-Za-z0-9_] is discarded rather than escaped. The DB layer
667 * screens them again; this keeps a bad name from being queried or echoed back
668 * by prepare_item_for_response() in the first place.
669 *
670 * health_site_status is dropped as well: every caller adds it explicitly for the
671 * health_score fast-path, and $never_in_response_fields reserves it out of the
672 * response, so accepting it here would only duplicate the entry.
673 *
674 * @param WP_REST_Request $request Request object.
675 *
676 * @return array Custom field names safe to query.
677 */
678 protected function get_requested_custom_fields( $request ) {
679 if ( ! isset( $request['custom_fields'] ) ) {
680 return array();
681 }
682
683 $fields = array_map( 'trim', wp_parse_list( $request['custom_fields'] ) );
684
685 $fields = array_filter(
686 $fields,
687 function ( $name ) {
688 return 'health_site_status' !== $name && preg_match( '/^[A-Za-z0-9_]+$/', $name );
689 }
690 );
691
692 return array_values( array_unique( $fields ) );
693 }
694
695 /**
696 * Get item.
697 *
698 * @param WP_REST_Request $request Request object.
699 *
700 * @return array|mixed Response data, ready for insertion into collection data.
701 */
702 public function get_item( $request ) { // phpcs:ignore -- NOSONAR - complex method.
703
704 $value = isset( $request['id_domain'] ) ? $request['id_domain'] : '';
705 $by = 'domain';
706
707 if ( is_numeric( $value ) ) {
708 $by = 'id';
709 } else {
710 $value = urldecode( $value );
711 }
712
713 // Extract with_tags flag early so both paths use the same value.
714 // Default to true for consistency with get_items() endpoint.
715 $with_tags = isset( $request['with_tags'] ) ? mainwp_string_to_bool( $request['with_tags'] ) : true;
716 $custom_fields = $this->get_requested_custom_fields( $request );
717
718 // Try abilities-first approach (with fallback to legacy logic).
719 $ability = function_exists( 'wp_get_ability' ) ? wp_get_ability( 'mainwp/get-site-v1' ) : null;
720
721 if ( null !== $ability ) {
722 // Build ability input: site_id_or_domain accepts either ID or domain.
723 $ability_input = array(
724 'site_id_or_domain' => $value,
725 'include_stats' => false, // REST doesn't request stats by default.
726 );
727
728 $result = $ability->execute( $ability_input );
729
730 if ( is_wp_error( $result ) ) {
731 $error_data = $result->get_error_data();
732 $status = isset( $error_data['status'] ) ? $error_data['status'] : 500;
733 return new WP_Error( $result->get_error_code(), $result->get_error_message(), array( 'status' => $status ) );
734 }
735
736 // DESIGN NOTE: The ability is used for ID discovery and access verification only.
737 // The ability returns a simplified schema optimized for AI/automation consumers,
738 // but REST API backward compatibility requires the full response format from
739 // prepare_item_for_response() + filter_response_data_by_allowed_fields().
740 // We re-fetch from DB to apply REST-specific options (with_tags, fields) that
741 // the ability schema does not support. This ensures consistent response format
742 // between ability and legacy paths.
743
744 $site_data = ! empty( $result ) && is_array( $result ) ? $result : array();
745 $site_id = isset( $site_data['id'] ) ? (int) $site_data['id'] : 0;
746
747 if ( $site_id > 0 ) {
748 $params = array(
749 'full_data' => true,
750 'selectgroups' => $with_tags,
751 'include' => array( $site_id ),
752 'extra_view' => array_merge( array( 'health_site_status' ), $custom_fields ), // Custom fields plus health_site_status, so prepare_item_for_response() reads the health data off the row instead of an extra per-site query.
753 // health_site_status must be in fields too: get_websites_for_current_user() maps rows through map_site(), which keeps only fields; extra_view alone would be dropped before prepare_item_for_response() sees it.
754 'fields' => array_merge( array( 'health_site_status' ), $this->get_fields_for_response( $request ) ),
755 );
756
757 // Required to ensure custom fields are included in the DB query for abilities path since ability schema does not support fields parameter, but legacy path does.
758 if ( ! empty( $custom_fields ) ) {
759 $params['fields'] = array_merge( $params['fields'], $custom_fields );
760 }
761
762 $websites = MainWP_DB::instance()->get_websites_for_current_user( $params );
763 $data = $websites ? current( $websites ) : array();
764 if ( ! empty( $data ) ) {
765 $site_data = $this->prepare_site_item_for_response_context( $data, $request, 'view', $custom_fields );
766 }
767 }
768
769 return rest_ensure_response( array( 'data' => $site_data ) );
770 }
771
772 // Fallback: Legacy logic when Abilities API is not available.
773 $prepared_args = array(
774 'with_tags' => $with_tags,
775 );
776
777 $item = $this->get_site_by( $by, $value, $prepared_args );
778
779 if ( is_wp_error( $item ) ) {
780 return $item;
781 }
782
783 $params = array(
784 'full_data' => true,
785 'selectgroups' => $with_tags,
786 'include' => array( $item->id ),
787 'extra_view' => array_merge( array( 'health_site_status' ), $custom_fields ), // Custom fields plus health_site_status, so prepare_item_for_response() reads the health data off the row instead of an extra per-site query.
788 // health_site_status must be in fields too: get_websites_for_current_user() maps rows through map_site(), which keeps only fields; extra_view alone would be dropped before prepare_item_for_response() sees it.
789 'fields' => array_merge( array( 'health_site_status' ), $this->get_fields_for_response( $request ) ),
790 );
791
792 // Required to ensure custom fields are included in the DB query for abilities path since ability schema does not support fields parameter, but legacy path does.
793 if ( ! empty( $custom_fields ) ) {
794 $params['fields'] = array_merge( $params['fields'], $custom_fields );
795 }
796
797 $websites = MainWP_DB::instance()->get_websites_for_current_user( $params );
798 $data = $websites ? current( $websites ) : array();
799 if ( ! empty( $data ) ) {
800 $data = $this->prepare_site_item_for_response_context( $data, $request, 'view', $custom_fields );
801 }
802 return rest_ensure_response( array( 'data' => $data ) );
803 }
804
805
806
807 /**
808 * Get all sites.
809 *
810 * @param WP_REST_Request $request Full details about the request.
811 * @return WP_Error|WP_REST_Response
812 */
813 public function get_items( $request ) { // phpcs:ignore -- NOSONAR - complex method.
814
815 $args = $this->prepare_objects_query( $request );
816 $args = $this->validate_rest_args( $args, $this->get_validate_args_params( 'get_sites' ) );
817 if ( is_wp_error( $args ) ) {
818 return $args;
819 }
820
821 // Check REST-specific flags that affect response format.
822 // These are processed in the legacy path but not in the ability schema.
823 $with_tags = isset( $request['with_tags'] ) ? mainwp_string_to_bool( $request['with_tags'] ) : true;
824 $full_data = isset( $request['full_data'] ) ? mainwp_string_to_bool( $request['full_data'] ) : true;
825 $custom_fields = $this->get_requested_custom_fields( $request );
826
827 // Try abilities-first approach (with fallback to legacy logic).
828 // Skip abilities when with_tags=false, full_data=false, or include/exclude
829 // are set (ability returns paginated results that would corrupt metadata).
830 $ability = null;
831 if ( empty( $args['include'] ) && empty( $args['exclude'] ) && $with_tags && $full_data ) {
832 $ability = function_exists( 'wp_get_ability' ) ? wp_get_ability( 'mainwp/list-sites-v1' ) : null;
833 }
834
835 if ( null !== $ability ) {
836 // Build ability input from REST parameters.
837 $ability_input = array(
838 'page' => isset( $args['paged'] ) ? (int) $args['paged'] : 1,
839 'per_page' => isset( $args['items_per_page'] ) ? (int) $args['items_per_page'] : 20,
840 'status' => isset( $args['status'] ) ? $args['status'] : 'any',
841 'search' => isset( $args['s'] ) ? $args['s'] : '',
842 'tag_id' => isset( $args['group_id'] ) ? (int) $args['group_id'] : null,
843 'client_id' => isset( $args['client_id'] ) ? (int) $args['client_id'] : null,
844 );
845
846 // Remove null values to avoid passing unneeded parameters.
847 $ability_input = array_filter( $ability_input, fn( $v ) => null !== $v );
848
849 // Remove empty string values as well to avoid passing empty search params.
850 $ability_input = array_filter( $ability_input, fn( $v ) => '' !== $v );
851
852 $result = $ability->execute( $ability_input );
853
854 if ( is_wp_error( $result ) ) {
855 $error_data = $result->get_error_data();
856 $status = isset( $error_data['status'] ) ? $error_data['status'] : 500;
857 return new WP_Error( $result->get_error_code(), $result->get_error_message(), array( 'status' => $status ) );
858 }
859
860 // DESIGN NOTE: The ability is used for filtering, pagination, and access verification.
861 // The ability returns a simplified schema optimized for AI/automation consumers,
862 // but REST API backward compatibility requires the full response format from
863 // prepare_item_for_response() + filter_response_data_by_allowed_fields().
864 // We re-fetch from DB using the IDs returned by the ability to apply REST-specific
865 // options (with_tags, fields) that the ability schema does not support.
866 // This ensures consistent response format between ability and legacy paths.
867 $normalized_items = array();
868 if ( ! empty( $result['items'] ) ) {
869 // Get site IDs from ability results.
870 $site_ids = array_map( fn( $item ) => isset( $item['id'] ) ? (int) $item['id'] : 0, $result['items'] );
871 $site_ids = array_filter( $site_ids );
872
873 if ( ! empty( $site_ids ) ) {
874 // Fetch full site data to normalize through REST filters.
875 $params = array(
876 'full_data' => true,
877 'selectgroups' => true,
878 'include' => $site_ids,
879 'extra_view' => array_merge( array( 'health_site_status' ), $custom_fields ), // Custom fields plus health_site_status, so prepare_item_for_response() reads the health data off the row instead of an extra per-site query.
880 // health_site_status must be in fields too: get_websites_for_current_user() maps rows through map_site(), which keeps only fields; extra_view alone would be dropped before prepare_item_for_response() sees it.
881 'fields' => array_merge( array( 'health_site_status' ), $this->get_fields_for_response( $request ) ),
882 );
883
884 // Required to ensure custom fields are included in the DB query for abilities path since ability schema does not support fields parameter, but legacy path does.
885 if ( ! empty( $custom_fields ) ) {
886 $params['fields'] = array_merge( $params['fields'], $custom_fields );
887 }
888
889 $websites = MainWP_DB::instance()->get_websites_for_current_user( $params );
890
891 if ( $websites ) {
892 foreach ( $websites as $site ) {
893 $normalized_items[] = $this->prepare_site_item_for_response_context( $site, $request, 'view', $custom_fields );
894 }
895 }
896 }
897 }
898
899 return rest_ensure_response(
900 array(
901 'success' => 1,
902 'total' => isset( $result['total'] ) ? (int) $result['total'] : count( $normalized_items ),
903 'data' => $normalized_items,
904 )
905 );
906 }
907
908 // Fallback: Legacy logic when Abilities API is not available.
909 $args['selectgroups'] = isset( $request['with_tags'] ) ? mainwp_string_to_bool( $request['with_tags'] ) : true;
910 $args['full_data'] = isset( $request['full_data'] ) ? mainwp_string_to_bool( $request['full_data'] ) : true;
911 // Fetch health_site_status onto the rows (extra_view = SQL column, fields =
912 // what map_site() keeps) so prepare_item_for_response() reads the health data
913 // off the object instead of a per-site query.
914 $args['extra_view'] = array_merge( array( 'health_site_status' ), $custom_fields );
915 $args_fields = isset( $args['fields'] ) && is_array( $args['fields'] ) ? $args['fields'] : array();
916 $args['fields'] = array_merge( array( 'health_site_status' ), $args_fields );
917
918 // get data.
919 $websites = MainWP_DB::instance()->get_websites_for_current_user( $args );
920
921 $data = array();
922
923 if ( $websites ) {
924 foreach ( $websites as $site ) {
925 $data[] = $this->prepare_site_item_for_response_context( $site, $request, 'view', $custom_fields );
926 }
927 }
928 return rest_ensure_response(
929 array(
930 'success' => 1,
931 'total' => count( $data ),
932 'data' => $data,
933 )
934 );
935 }
936
937 /**
938 * Get all sites.
939 *
940 * @param WP_REST_Request $request Full details about the request.
941 * @return WP_Error|WP_REST_Response
942 */
943 public function get_basic_items( $request ) {
944
945 $args = $this->prepare_objects_query( $request );
946 $args = $this->validate_rest_args( $args, $this->get_validate_args_params( 'get_sites' ) );
947
948 if ( is_wp_error( $args ) ) {
949 return $args;
950 }
951
952 $args['selectgroups'] = isset( $request['with_tags'] ) ? mainwp_string_to_bool( $request['with_tags'] ) : false;
953 $args['full_data'] = isset( $request['full_data'] ) ? mainwp_string_to_bool( $request['full_data'] ) : false;
954 // health_score has context 'view', so get_fields_for_response() still lists it here
955 // (the request context defaults to 'view') and prepare_item_for_response() computes it
956 // even though the simple_view filter later strips it. Project health_site_status onto the
957 // rows so that computation reads off the object instead of a per-site option query.
958 $args['extra_view'] = array( 'health_site_status' );
959 $args_fields = isset( $args['fields'] ) && is_array( $args['fields'] ) ? $args['fields'] : array();
960 $args['fields'] = array_merge( array( 'health_site_status' ), $args_fields );
961
962 // get data.
963 $websites = MainWP_DB::instance()->get_websites_for_current_user( $args );
964
965 $data = array();
966
967 if ( $websites ) {
968 foreach ( $websites as $site ) {
969 $data[] = $this->prepare_site_item_for_response_context( $site, $request, 'simple_view' );
970 }
971 }
972 return rest_ensure_response(
973 array(
974 'success' => 1,
975 'total' => count( $data ),
976 'data' => $data,
977 )
978 );
979 }
980
981
982 /**
983 * Get the query params for collections.
984 *
985 * @return array
986 */
987 public function get_collection_params() {
988 $params = array();
989 $params['context']['default'] = 'view';
990 return $params;
991 }
992
993 /**
994 * Count all sites.
995 *
996 * @param WP_REST_Request $request Request object.
997 *
998 * @return WP_Error|WP_REST_Response
999 */
1000 public function count_items( $request ) {
1001 $args = $this->prepare_objects_query( $request );
1002
1003 $args = $this->validate_rest_args( $args, $this->get_validate_args_params( 'get_sites' ) );
1004 if ( is_wp_error( $args ) ) {
1005 return $args;
1006 }
1007
1008 $params = array(
1009 'exclude' => isset( $args['exclude'] ) ? $args['exclude'] : array(),
1010 'include' => isset( $args['include'] ) ? $args['include'] : array(),
1011 'status' => isset( $args['status'] ) ? $args['status'] : array(),
1012 );
1013
1014 // get data.
1015 $websites = MainWP_DB::instance()->get_websites_for_current_user( $params );
1016 $data = array(
1017 'count' => count( $websites ),
1018 );
1019 return rest_ensure_response( $data );
1020 }
1021
1022 /**
1023 * Sync sites.
1024 *
1025 * @param WP_REST_Request $request Full details about the request.
1026 *
1027 * @return WP_Error|WP_REST_Response
1028 */
1029 public function sync_item( $request ) { // phpcs:ignore -- NOSONAR - complex.
1030
1031 $website = $this->get_request_item( $request );
1032
1033 if ( is_wp_error( $website ) ) {
1034 return $website;
1035 }
1036
1037 // Try abilities-first approach (with fallback to legacy logic).
1038 $ability = function_exists( 'wp_get_ability' ) ? wp_get_ability( 'mainwp/sync-sites-v1' ) : null;
1039
1040 if ( null !== $ability ) {
1041 // Build ability input: single site as array.
1042 $ability_input = array(
1043 'site_ids' => array( (int) $website->id ),
1044 );
1045
1046 $result = $ability->execute( $ability_input );
1047
1048 if ( is_wp_error( $result ) ) {
1049 $error_data = $result->get_error_data();
1050 $status = isset( $error_data['status'] ) ? $error_data['status'] : 500;
1051 return new WP_Error( $result->get_error_code(), $result->get_error_message(), array( 'status' => $status ) );
1052 }
1053
1054 // Transform ability output to REST response format.
1055 // The sync-sites-v1 ability returns 'synced' and 'errors' arrays.
1056 // A single-site sync is successful if the site appears in 'synced' and not in 'errors'.
1057 $synced = isset( $result['synced'] ) && is_array( $result['synced'] ) ? $result['synced'] : array();
1058 $errors = isset( $result['errors'] ) && is_array( $result['errors'] ) ? $result['errors'] : array();
1059 $success = ! empty( $synced ) && empty( $errors );
1060 $site_data = array();
1061
1062 // Normalize site data to REST format for backward compatibility.
1063 if ( $success ) {
1064 $params = array(
1065 'full_data' => true,
1066 'selectgroups' => true,
1067 'include' => array( $website->id ),
1068 'fields' => $this->get_fields_for_response( $request ),
1069 );
1070 $websites = MainWP_DB::instance()->get_websites_for_current_user( $params );
1071 $data = $websites ? current( $websites ) : array();
1072 if ( ! empty( $data ) ) {
1073 $site_data = $this->prepare_site_item_for_response_context( $data, $request, 'view' );
1074 }
1075 }
1076
1077 return rest_ensure_response(
1078 array(
1079 'success' => $success ? 1 : 0,
1080 'data' => $site_data,
1081 )
1082 );
1083 }
1084
1085 // Fallback: Legacy logic when Abilities API is not available.
1086 try {
1087 $success = MainWP_Sync::sync_site( $website );
1088 } catch ( \Exception $e ) {
1089 return new WP_Error( 'mainwp_rest_sync_site_error', $e->getMessage(), array( 'status' => 404 ) );
1090 }
1091
1092 $data = array();
1093
1094 if ( $success ) {
1095 $params = array(
1096 'full_data' => true,
1097 'selectgroups' => true,
1098 'include' => array( $website->id ),
1099 'fields' => $this->get_fields_for_response( $request ),
1100 );
1101 $websites = MainWP_DB::instance()->get_websites_for_current_user( $params );
1102 $data = $websites ? current( $websites ) : array();
1103 $data = $this->prepare_site_item_for_response_context( $data, $request, 'view' );
1104 }
1105
1106 return rest_ensure_response(
1107 array(
1108 'success' => $success ? 1 : 0,
1109 'data' => $data,
1110 )
1111 );
1112 }
1113
1114
1115 /**
1116 * Secure site info.
1117 *
1118 * @param WP_REST_Request $request Full details about the request.
1119 *
1120 * @return WP_Error|WP_REST_Response
1121 */
1122 public function security_item( $request ) {
1123
1124 $website = $this->get_request_item( $request );
1125
1126 if ( is_wp_error( $website ) ) {
1127 return $website;
1128 }
1129
1130 try {
1131 $result = MainWP_Connect::fetch_url_authed( $website, 'security' );
1132 } catch ( \Exception $e ) {
1133 return new WP_Error( 'mainwp_rest_get_secure_site_error', $e->getMessage(), array( 'status' => 404 ) );
1134 }
1135
1136 $data = array(
1137 'data' => $result,
1138 'site' => $this->filter_response_data_by_allowed_fields( $website, 'simple_view' ),
1139 );
1140
1141 return rest_ensure_response( $data );
1142 }
1143
1144 /**
1145 * Get plugins of site.
1146 *
1147 * @param WP_REST_Request $request Full details about the request.
1148 *
1149 * @return WP_Error|WP_REST_Response
1150 */
1151 public function get_site_plugins( $request ) { //phpcs:ignore -- NOSONAR - complex.
1152
1153 $website = $this->get_request_item( $request );
1154
1155 if ( is_wp_error( $website ) ) {
1156 return $website;
1157 }
1158
1159 $args = $this->prepare_objects_query( $request );
1160 $args = $this->validate_rest_args( $args, $this->get_validate_args_params( 'site_plugins' ) );
1161
1162 if ( is_wp_error( $args ) ) {
1163 return $args;
1164 }
1165
1166 // Try abilities-first approach (with fallback to legacy logic).
1167 // Skip abilities if unsupported parameters are present.
1168 // The ability schema doesn't support search, must_use, or pagination.
1169 // Note: Pagination only applies when BOTH paged AND items_per_page are set (matches legacy).
1170 $ability = null;
1171 $has_unsupported_params = ! empty( $args['s'] ) || ! empty( $args['must_use'] )
1172 || ( ! empty( $args['paged'] ) && ! empty( $args['items_per_page'] ) );
1173
1174 if ( ! $has_unsupported_params ) {
1175 $ability = function_exists( 'wp_get_ability' ) ? wp_get_ability( 'mainwp/get-site-plugins-v1' ) : null;
1176 }
1177
1178 if ( null !== $ability ) {
1179 // Build ability input matching the ability's JSON Schema.
1180 $ability_input = array(
1181 'site_id_or_domain' => (int) $website->id,
1182 );
1183
1184 // Status must be a string matching the ability schema enum ['all', 'active', 'inactive'].
1185 if ( isset( $args['status'] ) && in_array( $args['status'], array( 'all', 'active', 'inactive' ), true ) ) {
1186 $ability_input['status'] = $args['status'];
1187 } else {
1188 $ability_input['status'] = 'all';
1189 }
1190
1191 // Pass has_update filter if provided.
1192 if ( isset( $args['has_update'] ) ) {
1193 $ability_input['has_update'] = (bool) $args['has_update'];
1194 }
1195
1196 $result = $ability->execute( $ability_input );
1197
1198 if ( is_wp_error( $result ) ) {
1199 $error_data = $result->get_error_data();
1200 $status = isset( $error_data['status'] ) ? $error_data['status'] : 500;
1201 return new WP_Error( $result->get_error_code(), $result->get_error_message(), array( 'status' => $status ) );
1202 }
1203
1204 // Transform ability output to REST response format.
1205 // Ability returns 'plugins' array and 'total', REST includes site info.
1206 $plugins = isset( $result['plugins'] ) && is_array( $result['plugins'] ) ? $result['plugins'] : array();
1207 return rest_ensure_response(
1208 array(
1209 'success' => 1,
1210 'total' => isset( $result['total'] ) ? $result['total'] : count( $plugins ),
1211 'data' => $plugins,
1212 'site' => $this->filter_response_data_by_allowed_fields( $website, 'simple_view' ),
1213 )
1214 );
1215 }
1216
1217 // Fallback: Legacy logic when Abilities API is not available.
1218 $data = json_decode( $website->plugins, 1 );
1219
1220 if ( is_array( $data ) ) {
1221 if ( ! empty( $args['must_use'] ) ) {
1222 $data = MainWP_Utility::get_sub_array_having( $data, 'mu', 1 );
1223 }
1224 if ( ! empty( $args['s'] ) ) {
1225 $s = trim( $args['s'] );
1226 if ( ! empty( $s ) ) {
1227 $data = mainwp_search_in_array(
1228 $data,
1229 $s,
1230 array(
1231 'search_in_key' => false,
1232 'in_sub_fields' => array( 'name', 'description', 'version', 'slug' ),
1233 'use_index_result' => false,
1234 )
1235 );
1236 }
1237 }
1238
1239 if ( ! empty( $args['status'] ) && is_array( $args['status'] ) && ! in_array( 'any', $args['status'] ) ) {
1240
1241 $status_act = in_array( 'active', $args['status'] ) ? 1 : 0;
1242 $status_inact = in_array( 'inactive', $args['status'] ) ? 1 : 0;
1243
1244 $status_val = false;
1245 if ( $status_act && $status_inact ) {
1246 $status_val = false;
1247 } elseif ( $status_act ) {
1248 $status_val = 1;
1249 } elseif ( $status_inact ) {
1250 $status_val = 0;
1251 }
1252
1253 if ( false !== $status_val ) {
1254 $data = MainWP_Utility::get_sub_array_having( $data, 'active', $status_val );
1255 }
1256 }
1257 if ( ! empty( $args['paged'] ) && ! empty( $args['items_per_page'] ) ) {
1258 $pag = (int) $args['paged'];
1259 $per = (int) $args['items_per_page'];
1260 $data = MainWP_Utility::get_sub_array_with_limit( $data, $per * ( $pag - 1 ), $per );
1261 }
1262 }
1263
1264 $resp_data = array(
1265 'success' => 1,
1266 'total' => is_array( $data ) ? count( $data ) : 0,
1267 'data' => $data,
1268 'site' => $this->filter_response_data_by_allowed_fields( $website, 'simple_view' ),
1269 );
1270 return rest_ensure_response( $resp_data );
1271 }
1272
1273
1274 /**
1275 * Handle plugins actions of site.
1276 *
1277 * @param object $website object.
1278 * @param string $action action.
1279 * @param string $slugs plugins slugs.
1280 *
1281 * @return WP_Error|array
1282 */
1283 public function handle_plugins_actions_of_site( $website, $action, $slugs ) {
1284 try {
1285 $information = MainWP_Connect::fetch_url_authed(
1286 $website,
1287 'plugin_action',
1288 array(
1289 'action' => $action,
1290 'plugin' => implode( '||', $slugs ),
1291 )
1292 );
1293
1294 $success = false;
1295 $data = array();
1296 if ( is_array( $information ) && isset( $information['other_data']['plugin_action_data'] ) ) {
1297 $action_data = $information['other_data']['plugin_action_data'];
1298 foreach ( $action_data as $item ) {
1299 $success = true;
1300 $item = MainWP_Utility::instance()->sanitize_data( $item );
1301 if ( ! empty( $item ) && isset( $item['name'] ) ) {
1302 $data[] = array(
1303 'name' => $item['name'],
1304 'version' => $item['version'],
1305 'slug' => $item['slug'],
1306 );
1307 }
1308 }
1309 }
1310
1311 return array(
1312 'success' => $success ? 1 : 0,
1313 'data' => $data,
1314 );
1315
1316 } catch ( \Exception $e ) {
1317 return new WP_Error( "mainwp_rest_{$action}_plugins_action", $e->getMessage() );
1318 }
1319 }
1320
1321
1322 /**
1323 * Activate plugins of site.
1324 *
1325 * @param WP_REST_Request $request Full details about the request.
1326 *
1327 * @return WP_Error|WP_REST_Response
1328 */
1329 public function activate_plugins_of_site( $request ) {
1330 $website = $this->get_request_item( $request );
1331 if ( is_wp_error( $website ) ) {
1332 return $website;
1333 }
1334
1335 $slugs = isset( $request['slug'] ) ? array_map( 'trim', wp_parse_list( $request['slug'] ) ) : array();
1336 if ( empty( $slugs ) ) {
1337 return new WP_Error( 'mainwp_rest_invalid_plugins_slugs', __( 'Plugins slugs are empty or invalid.', 'mainwp' ), 400 );
1338 }
1339 $result = $this->handle_plugins_actions_of_site( $website, 'activate', $slugs );
1340
1341 if ( is_wp_error( $result ) ) {
1342 return $result;
1343 }
1344
1345 $resp_data = array(
1346 'success' => ! empty( $result['success'] ) ? 1 : 0,
1347 'data' => ! empty( $result['data'] ) ? $result['data'] : array(),
1348 'site' => $this->filter_response_data_by_allowed_fields( $website, 'simple_view' ),
1349 );
1350
1351 return rest_ensure_response( $resp_data );
1352 }
1353
1354 /**
1355 * Deactivate plugins of site.
1356 *
1357 * @param WP_REST_Request $request Full details about the request.
1358 *
1359 * @return WP_Error|WP_REST_Response
1360 */
1361 public function deactivate_plugins_of_site( $request ) {
1362 $website = $this->get_request_item( $request );
1363 if ( is_wp_error( $website ) ) {
1364 return $website;
1365 }
1366
1367 $slugs = isset( $request['slug'] ) ? array_map( 'trim', wp_parse_list( $request['slug'] ) ) : array();
1368 if ( empty( $slugs ) ) {
1369 return new WP_Error( 'mainwp_rest_invalid_plugins_slugs', __( 'Plugins slug is empty or invalid.', 'mainwp' ), 400 );
1370 }
1371 $result = $this->handle_plugins_actions_of_site( $website, 'deactivate', $slugs );
1372
1373 if ( is_wp_error( $result ) ) {
1374 return $result;
1375 }
1376 $resp_data = array(
1377 'success' => ! empty( $result['success'] ) ? 1 : 0,
1378 'data' => ! empty( $result['data'] ) ? $result['data'] : array(),
1379 'site' => $this->filter_response_data_by_allowed_fields( $website, 'simple_view' ),
1380 );
1381
1382 return rest_ensure_response( $resp_data );
1383 }
1384
1385 /**
1386 * Delete plugins of site.
1387 *
1388 * @param WP_REST_Request $request Full details about the request.
1389 *
1390 * @return WP_Error|WP_REST_Response
1391 */
1392 public function delete_plugins_of_site( $request ) {
1393 $website = $this->get_request_item( $request );
1394 if ( is_wp_error( $website ) ) {
1395 return $website;
1396 }
1397 $slugs = isset( $request['slug'] ) ? array_map( 'trim', wp_parse_list( $request['slug'] ) ) : array();
1398 if ( empty( $slugs ) ) {
1399 return new WP_Error( 'mainwp_rest_invalid_plugins_slugs', __( 'Plugins slugs is empty or invalid.', 'mainwp' ), 400 );
1400 }
1401 $result = $this->handle_plugins_actions_of_site( $website, 'delete', $slugs );
1402
1403 if ( is_wp_error( $result ) ) {
1404 return $result;
1405 }
1406 $resp_data = array(
1407 'success' => ! empty( $result['success'] ) ? 1 : 0,
1408 'data' => ! empty( $result['data'] ) ? $result['data'] : array(),
1409 'site' => $this->filter_response_data_by_allowed_fields( $website, 'simple_view' ),
1410 );
1411
1412 return rest_ensure_response( $resp_data );
1413 }
1414
1415 /**
1416 * Get abandoned plugins of site.
1417 *
1418 * @param WP_REST_Request $request Full details about the request.
1419 *
1420 * @return WP_Error|WP_REST_Response
1421 */
1422 public function get_abandoned_plugins_of_site( $request ) {
1423
1424 $website = $this->get_request_item( $request );
1425 if ( is_wp_error( $website ) ) {
1426 return $website;
1427 }
1428
1429 // get data.
1430 $data = MainWP_DB::instance()->get_website_option( $website, 'plugins_outdate_info' );
1431 $data = ! empty( $data ) ? json_decode( $data, true ) : array();
1432
1433 $resp_data = array(
1434 'success' => 1,
1435 'total' => is_array( $data ) ? count( $data ) : 0,
1436 'data' => $data,
1437 'site' => $this->filter_response_data_by_allowed_fields( $website, 'simple_view' ),
1438 );
1439
1440 return rest_ensure_response( $resp_data );
1441 }
1442
1443
1444 /**
1445 * Get themes of site.
1446 *
1447 * @param WP_REST_Request $request Full details about the request.
1448 *
1449 * @return WP_Error|WP_REST_Response
1450 */
1451 public function get_site_themes( $request ) { //phpcs:ignore - NOSONAR - complex.
1452 $website = $this->get_request_item( $request );
1453 if ( is_wp_error( $website ) ) {
1454 return $website;
1455 }
1456
1457 $args = $this->prepare_objects_query( $request );
1458
1459 $args = $this->validate_rest_args( $args, $this->get_validate_args_params( 'site_themes' ) );
1460 if ( is_wp_error( $args ) ) {
1461 return $args;
1462 }
1463
1464 if ( isset( $args['status'] ) ) {
1465 $args['status'] = array_unique( wp_parse_list( $args['status'] ) );
1466 }
1467
1468 // Try abilities-first approach (with fallback to legacy logic).
1469 // Skip abilities if unsupported parameters are present.
1470 // The ability schema doesn't support search or pagination.
1471 // Note: Pagination only applies when BOTH paged AND items_per_page are set (matches legacy).
1472 $ability = null;
1473 $has_unsupported_params = ! empty( $args['s'] )
1474 || ( ! empty( $args['paged'] ) && ! empty( $args['items_per_page'] ) );
1475
1476 if ( ! $has_unsupported_params ) {
1477 $ability = function_exists( 'wp_get_ability' ) ? wp_get_ability( 'mainwp/get-site-themes-v1' ) : null;
1478 }
1479
1480 if ( null !== $ability ) {
1481 // Build ability input matching the ability's JSON Schema.
1482 $ability_input = array(
1483 'site_id_or_domain' => (int) $website->id,
1484 );
1485
1486 // Status must be a string matching the ability schema enum ['all', 'active', 'inactive'].
1487 if ( isset( $args['status'] ) && in_array( $args['status'], array( 'all', 'active', 'inactive' ), true ) ) {
1488 $ability_input['status'] = $args['status'];
1489 } else {
1490 $ability_input['status'] = 'all';
1491 }
1492
1493 // Pass has_update filter if provided.
1494 if ( isset( $args['has_update'] ) ) {
1495 $ability_input['has_update'] = (bool) $args['has_update'];
1496 }
1497
1498 $result = $ability->execute( $ability_input );
1499
1500 if ( is_wp_error( $result ) ) {
1501 $error_data = $result->get_error_data();
1502 $status = isset( $error_data['status'] ) ? $error_data['status'] : 500;
1503 return new WP_Error( $result->get_error_code(), $result->get_error_message(), array( 'status' => $status ) );
1504 }
1505
1506 // Transform ability output to REST response format.
1507 // Ability returns 'themes' array and 'total', REST includes site info.
1508 $themes = isset( $result['themes'] ) && is_array( $result['themes'] ) ? $result['themes'] : array();
1509 return rest_ensure_response(
1510 array(
1511 'success' => 1,
1512 'total' => isset( $result['total'] ) ? $result['total'] : count( $themes ),
1513 'data' => $themes,
1514 'site' => $this->filter_response_data_by_allowed_fields( $website, 'simple_view' ),
1515 )
1516 );
1517 }
1518
1519 // Fallback: Legacy logic when Abilities API is not available.
1520 $data = json_decode( $website->themes, 1 );
1521 if ( is_array( $data ) ) {
1522 if ( ! empty( $args['s'] ) ) {
1523 $s = trim( $args['s'] );
1524 if ( ! empty( $s ) ) {
1525 $data = mainwp_search_in_array(
1526 $data,
1527 $s,
1528 array(
1529 'search_in_key' => false,
1530 'in_sub_fields' => array( 'name', 'description', 'version', 'slug', 'title' ),
1531 'use_index_result' => false,
1532 )
1533 );
1534 }
1535 }
1536
1537 if ( ! empty( $args['status'] ) ) {
1538
1539 $status_act = in_array( 'active', $args['status'] ) ? 1 : 0;
1540 $status_inact = in_array( 'inactive', $args['status'] ) ? 1 : 0;
1541
1542 $status_val = false;
1543 if ( $status_act && $status_inact ) {
1544 $status_val = false;
1545 } elseif ( $status_act ) {
1546 $status_val = 1;
1547 } elseif ( $status_inact ) {
1548 $status_val = 0;
1549 }
1550
1551 if ( false !== $status_val ) {
1552 $data = MainWP_Utility::get_sub_array_having( $data, 'active', $status_val );
1553 }
1554 }
1555
1556 if ( ! empty( $args['paged'] ) && ! empty( $args['items_per_page'] ) ) {
1557 $pag = (int) $args['paged'];
1558 $per = (int) $args['items_per_page'];
1559 $data = MainWP_Utility::get_sub_array_with_limit( $data, $per * ( $pag - 1 ), $per );
1560 }
1561 }
1562
1563 $resp_data = array(
1564 'success' => 1,
1565 'total' => is_array( $data ) ? count( $data ) : 0,
1566 'data' => $data,
1567 'site' => $this->filter_response_data_by_allowed_fields( $website, 'simple_view' ),
1568 );
1569 return rest_ensure_response( $resp_data );
1570 }
1571
1572
1573 /**
1574 * Handle plugins actions of site.
1575 *
1576 * @param object $website object.
1577 * @param string $action action.
1578 * @param string $slugs themes slugs.
1579 *
1580 * @return WP_Error|array
1581 */
1582 public function handle_theme_actions_of_site( $website, $action, $slugs ) { //phpcs:ignore -- NOSONAR - complex.
1583 try {
1584 $information = MainWP_Connect::fetch_url_authed(
1585 $website,
1586 'theme_action',
1587 array(
1588 'action' => $action,
1589 'theme' => $slugs,
1590 )
1591 );
1592
1593 if ( ! is_array( $information ) ) {
1594 $information = array();
1595 }
1596
1597 $success = 0;
1598 $data = array();
1599
1600 if ( 'delete' === $action && is_array( $information ) && ! empty( $information['error']['is_activated_theme'] ) ) {
1601 /* translators: %s: Theme name */
1602 $data['error'] = sprintf( esc_html__( 'The theme %s is active.', 'mainwp' ), esc_html( $information['error']['is_activated_theme'] ) );
1603 }
1604
1605 if ( 'activate' === $action && isset( $information['other_data']['theme_deactivate_data'] ) ) {
1606 $success = 1;
1607 $act_slug = 'deactivate';
1608 $data[ $act_slug ] = array();
1609 $action_data = $information['other_data']['theme_deactivate_data'];
1610 foreach ( $action_data as $item ) {
1611 $success = true;
1612 $item = MainWP_Utility::instance()->sanitize_data( $item );
1613 if ( ! empty( $item ) && isset( $item['name'] ) ) {
1614 $data[ $act_slug ] = array(
1615 'name' => $item['name'],
1616 'version' => $item['version'],
1617 'slug' => $item['slug'],
1618 );
1619 }
1620 }
1621 }
1622
1623 if ( in_array( $action, array( 'activate', 'delete' ) ) && isset( $information['other_data']['theme_action_data'] ) ) {
1624 $success = 1;
1625 if ( 'activate' === $action ) {
1626 $act_slug = 'activate';
1627 } else {
1628 $act_slug = 'delete';
1629 }
1630 $data[ $act_slug ] = array();
1631 $action_data = $information['other_data']['theme_action_data'];
1632 foreach ( $action_data as $item ) {
1633 $success = true;
1634 $item = MainWP_Utility::instance()->sanitize_data( $item );
1635 if ( ! empty( $item ) && isset( $item['name'] ) ) {
1636 $data[ $act_slug ] = array(
1637 'name' => $item['name'],
1638 'version' => $item['version'],
1639 'slug' => $item['slug'],
1640 );
1641 }
1642 }
1643 }
1644
1645 return array(
1646 'success' => $success,
1647 'data' => $data,
1648 );
1649
1650 } catch ( \Exception $e ) {
1651 return new WP_Error( "mainwp_rest_{$action}_plugins_action", $e->getMessage() );
1652 }
1653 }
1654
1655
1656 /**
1657 * Activate themes of site.
1658 *
1659 * @param WP_REST_Request $request Full details about the request.
1660 *
1661 * @return WP_Error|WP_REST_Response
1662 */
1663 public function activate_themes_of_site( $request ) {
1664 $website = $this->get_request_item( $request );
1665 if ( is_wp_error( $website ) ) {
1666 return $website;
1667 }
1668
1669 $slugs = isset( $request['slug'] ) && is_string( $request['slug'] ) ? trim( $request['slug'] ) : '';
1670 if ( empty( $slugs ) ) {
1671 return new WP_Error( 'mainwp_rest_invalid_themes_slugs', __( 'Theme slug is empty or invalid.', 'mainwp' ), 400 );
1672 }
1673
1674 $result = $this->handle_theme_actions_of_site( $website, 'activate', $slugs );
1675
1676 if ( is_wp_error( $result ) ) {
1677 return $result;
1678 }
1679 $resp_data = array(
1680 'success' => ! empty( $result['success'] ) ? 1 : 0,
1681 'data' => ! empty( $result['data'] ) ? $result['data'] : array(),
1682 'site' => $this->filter_response_data_by_allowed_fields( $website, 'simple_view' ),
1683 );
1684
1685 return rest_ensure_response( $resp_data );
1686 }
1687
1688
1689 /**
1690 * Delete themes of site.
1691 *
1692 * @param WP_REST_Request $request Full details about the request.
1693 *
1694 * @return WP_Error|WP_REST_Response
1695 */
1696 public function delete_themes_of_site( $request ) {
1697
1698 $website = $this->get_request_item( $request );
1699 if ( is_wp_error( $website ) ) {
1700 return $website;
1701 }
1702
1703 $slugs = isset( $request['slug'] ) ? array_map( 'trim', wp_parse_list( $request['slug'] ) ) : array();
1704 if ( empty( $slugs ) ) {
1705 return new WP_Error( 'mainwp_rest_invalid_themes_slugs', __( 'Theme slug is empty or invalid.', 'mainwp' ), 400 );
1706 }
1707
1708 $result = $this->handle_theme_actions_of_site( $website, 'delete', implode( '||', $slugs ) );
1709
1710 if ( is_wp_error( $result ) ) {
1711 return $result;
1712 }
1713 $resp_data = array(
1714 'success' => ! empty( $result['success'] ) ? 1 : 0,
1715 'data' => ! empty( $result['data'] ) ? $result['data'] : array(),
1716 'site' => $this->filter_response_data_by_allowed_fields( $website, 'simple_view' ),
1717 );
1718
1719 return rest_ensure_response( $resp_data );
1720 }
1721
1722
1723 /**
1724 * Get abandoned themes of site.
1725 *
1726 * @param WP_REST_Request $request Full details about the request.
1727 *
1728 * @return WP_Error|WP_REST_Response
1729 */
1730 public function get_abandoned_themes_of_site( $request ) {
1731
1732 $website = $this->get_request_item( $request );
1733 if ( is_wp_error( $website ) ) {
1734 return $website;
1735 }
1736
1737 // get data.
1738 $data = MainWP_DB::instance()->get_website_option( $website, 'themes_outdate_info' );
1739 $data = ! empty( $data ) ? json_decode( $data, true ) : array();
1740
1741 $resp_data = array(
1742 'success' => 1,
1743 'total' => is_array( $data ) ? count( $data ) : 0,
1744 'data' => $data,
1745 'site' => $this->filter_response_data_by_allowed_fields( $website, 'simple_view' ),
1746 );
1747
1748 return rest_ensure_response( $resp_data );
1749 }
1750
1751 /**
1752 * Get non-mainwp changes of site.
1753 *
1754 * @param WP_REST_Request $request Full details about the request.
1755 *
1756 * @return WP_Error|WP_REST_Response
1757 */
1758 public function get_non_mainwp_changes_of_site( $request ) { //phpcs:ignore -- NOSONAR -complex.
1759
1760 $website = $this->get_request_item( $request );
1761 if ( is_wp_error( $website ) ) {
1762 return $website;
1763 }
1764
1765 $args = $this->prepare_objects_query( $request );
1766
1767 $params = array(
1768 'wpid' => $website->id,
1769 'where_extra' => ' AND dismiss = 0 ',
1770 'order' => ! empty( $args['order'] ) ? sanitize_text_field( $args['order'] ) : '',
1771 'order_by' => ! empty( $args['orderby'] ) ? sanitize_text_field( $args['orderby'] ) : '',
1772 'source' => ! empty( $request['source'] ) ? sanitize_text_field( $request['source'] ) : 'wpadmin',
1773 );
1774
1775 if ( ! empty( $args['paged'] ) && ! empty( $args['items_per_page'] ) ) {
1776 $params['rowcount'] = intval( $args['items_per_page'] );
1777 $params['offset'] = ( (int) ( $args['paged'] ) - 1 ) * $params['rowcount'];
1778 } else {
1779 // support compatible api param.
1780 $limit = ! empty( $args['limit'] ) ? (int) ( $args['limit'] ) : 200;
1781 $params['rowcount'] = $limit;
1782 $params['offset'] = 0;
1783 }
1784
1785 $params['actions'] = ! empty( $request['actions'] ) ? sanitize_text_field( $request['actions'] ) : '';
1786 $params['contexts'] = ! empty( $request['contexts'] ) ? sanitize_text_field( $request['contexts'] ) : '';
1787 $params['total_count'] = ! empty( $request['total_count'] ) ? mainwp_string_to_bool( $request['total_count'] ) : false;
1788 $params['optimize_view'] = ! empty( $request['optimize_view'] ) ? mainwp_string_to_bool( $request['optimize_view'] ) : false;
1789 $params['optimize_with_meta'] = ! empty( $request['optimize_with_meta'] ) ? mainwp_string_to_bool( $request['optimize_with_meta'] ) : false;
1790
1791 $data = MainWP_DB_Site_Actions::instance()->get_wp_actions( $params, $website );
1792
1793 if ( $params['total_count'] ) {
1794 $total = $data;
1795 $data = array( 'total' => $total );
1796 } else {
1797 $total = is_array( $data ) ? count( $data ) : 0;
1798 }
1799
1800 $resp_data = array(
1801 'success' => 1,
1802 'total' => $total,
1803 'data' => $data,
1804 'site' => $this->filter_response_data_by_allowed_fields( $website, 'simple_view' ),
1805 );
1806 return rest_ensure_response( $resp_data );
1807 }
1808
1809 /**
1810 * Sync all sites.
1811 *
1812 * @param WP_REST_Request $request Full details about the request.
1813 *
1814 * @return WP_Error|WP_REST_Response
1815 */
1816 public function sync_items( $request ) { //phpcs:ignore -- NOSONAR - complex.
1817
1818 $args = $this->prepare_objects_query( $request );
1819 $params = array(
1820 'exclude' => isset( $args['exclude'] ) && ! empty( $args['exclude'] ) ? $args['exclude'] : array(),
1821 'include' => isset( $args['include'] ) && ! empty( $args['include'] ) ? $args['include'] : array(),
1822 );
1823
1824 // Try abilities-first approach (with fallback to legacy logic).
1825 $ability = function_exists( 'wp_get_ability' ) ? wp_get_ability( 'mainwp/sync-sites-v1' ) : null;
1826
1827 if ( null !== $ability ) {
1828 // Build ability input.
1829 $ability_input = array();
1830 if ( ! empty( $params['include'] ) ) {
1831 $raw_include = is_array( $params['include'] ) ? $params['include'] : wp_parse_list( $params['include'] );
1832 $include_ids = array_values( array_unique( array_filter( array_map( 'absint', $raw_include ) ) ) );
1833
1834 if ( empty( $include_ids ) ) {
1835 return new WP_Error(
1836 'mainwp_invalid_input',
1837 __( 'Invalid include parameter. Expected one or more positive site IDs.', 'mainwp' ),
1838 array( 'status' => 400 )
1839 );
1840 }
1841 $ability_input['site_ids'] = $include_ids;
1842 }
1843 if ( ! empty( $params['exclude'] ) ) {
1844 $raw_exclude = is_array( $params['exclude'] ) ? $params['exclude'] : wp_parse_list( $params['exclude'] );
1845 $exclude_ids = array_values( array_unique( array_filter( array_map( 'absint', $raw_exclude ) ) ) );
1846 if ( empty( $exclude_ids ) ) {
1847 return new WP_Error(
1848 'mainwp_invalid_input',
1849 __( 'Invalid exclude parameter. Expected one or more positive site IDs.', 'mainwp' ),
1850 array( 'status' => 400 )
1851 );
1852 }
1853 $ability_input['exclude_ids'] = $exclude_ids;
1854 }
1855
1856 $result = $ability->execute( $ability_input );
1857
1858 if ( is_wp_error( $result ) ) {
1859 $error_data = $result->get_error_data();
1860 $status = isset( $error_data['status'] ) ? $error_data['status'] : 500;
1861 return new WP_Error( $result->get_error_code(), $result->get_error_message(), array( 'status' => $status ) );
1862 }
1863
1864 // Check if sync was queued for background processing.
1865 if ( isset( $result['job_id'] ) ) {
1866 return rest_ensure_response(
1867 array(
1868 'success' => 1,
1869 'message' => esc_html__( 'Sync queued for background processing.', 'mainwp' ),
1870 'job_id' => $result['job_id'],
1871 'total' => isset( $result['sites_queued'] ) ? $result['sites_queued'] : 0,
1872 )
1873 );
1874 }
1875
1876 // Transform ability output: map synced/errors arrays to REST format.
1877 // The sync-sites-v1 ability returns 'synced' and 'errors' arrays.
1878 // REST expects site ID as key with 'result' field indicating success/failure.
1879 $transformed_data = array();
1880 $synced = isset( $result['synced'] ) && is_array( $result['synced'] ) ? $result['synced'] : array();
1881 $errors = isset( $result['errors'] ) && is_array( $result['errors'] ) ? $result['errors'] : array();
1882
1883 // Process successfully synced sites.
1884 foreach ( $synced as $site_info ) {
1885 $site_id = isset( $site_info['id'] ) ? (int) $site_info['id'] : 0;
1886 if ( $site_id > 0 ) {
1887 // Re-fetch full site data for REST response format.
1888 $site_params = array(
1889 'full_data' => true,
1890 'selectgroups' => true,
1891 'include' => array( $site_id ),
1892 'fields' => $this->get_fields_for_response( $request ),
1893 );
1894 $websites = MainWP_DB::instance()->get_websites_for_current_user( $site_params );
1895 $site_data = $websites ? current( $websites ) : array();
1896 if ( ! empty( $site_data ) ) {
1897 $site_data = $this->filter_response_data_by_allowed_fields(
1898 $this->prepare_item_for_response( $site_data, $request ),
1899 'view'
1900 );
1901 }
1902 $transformed_data[ $site_id ] = array_merge( array( 'result' => 'success' ), $site_data );
1903 }
1904 }
1905
1906 // Process failed sites.
1907 foreach ( $errors as $error_info ) {
1908 $site_id = isset( $error_info['identifier'] ) ? (int) $error_info['identifier'] : 0;
1909 if ( $site_id > 0 ) {
1910 $transformed_data[ $site_id ] = array( 'result' => 'failed' );
1911 }
1912 }
1913
1914 update_option( 'mainwp_last_synced_all_sites', time() );
1915 return rest_ensure_response(
1916 array(
1917 'total' => count( $transformed_data ),
1918 'data' => $transformed_data,
1919 )
1920 );
1921 }
1922
1923 // Fallback: Legacy logic when Abilities API is not available.
1924 $data = array();
1925 $websites = MainWP_DB::instance()->query( MainWP_DB::instance()->get_sql_websites_for_current_user( false, null, 'wp.url', false, false, null, false, array( 'favi_icon' ), 'no', $params ) );
1926 while ( $websites && ( $website = MainWP_DB::fetch_object( $websites ) ) ) {
1927 try {
1928 $ret = MainWP_Sync::sync_site( $website );
1929 } catch ( \Exception $e ) {
1930 $ret = false;
1931 }
1932 $item = $this->prepare_site_item_for_response_context( $website, $request, 'view' );
1933 $data[ $website->id ] = array_merge( array( 'result' => $ret ? 'success' : 'failed' ), $item );
1934 }
1935 MainWP_DB::free_result( $websites );
1936 update_option( 'mainwp_last_synced_all_sites', time() );
1937 return rest_ensure_response(
1938 array(
1939 'total' => count( $data ),
1940 'data' => $data,
1941 )
1942 );
1943 }
1944
1945 /**
1946 * Reconnect site.
1947 *
1948 * @param WP_REST_Request $request Full details about the request.
1949 *
1950 * @return WP_Error|WP_REST_Response
1951 */
1952 public function reconnect_item( $request ) {
1953 $website = $this->get_request_item( $request );
1954 if ( is_wp_error( $website ) ) {
1955 return $website;
1956 }
1957 $resp_data = array();
1958 try {
1959 $ret = MainWP_Manage_Sites_View::m_reconnect_site( $website );
1960 $resp_data['success'] = $ret ? 1 : 0;
1961 } catch ( \Exception $e ) {
1962 // failed.
1963 /* translators: %d: Site ID */
1964 return new \WP_Error( 'mainwp_rest_reconnect_site_error', sprintf( esc_html__( 'Reconnect Site "%d" error:', 'mainwp' ), $website->id ) . ': ' . $e->getMessage() );
1965 }
1966 $website = $this->get_site_by( 'id', $website->id );
1967 $resp_data['data'] = $this->filter_response_data_by_allowed_fields( $website, 'simple_view' );
1968 return rest_ensure_response( $resp_data );
1969 }
1970
1971 /**
1972 * Reconnect sites.
1973 *
1974 * @param WP_REST_Request $request Full details about the request.
1975 *
1976 * @return WP_Error|WP_REST_Response
1977 */
1978 public function reconnect_items( $request ) {
1979
1980 $args = $this->prepare_objects_query( $request );
1981 $params = array(
1982 'exclude' => isset( $args['exclude'] ) && ! empty( $args['exclude'] ) ? $args['exclude'] : array(),
1983 'include' => isset( $args['include'] ) && ! empty( $args['include'] ) ? $args['include'] : array(),
1984 );
1985
1986 $data = array();
1987 $websites = MainWP_DB::instance()->query( MainWP_DB::instance()->get_sql_websites_for_current_user( false, null, 'wp.url', false, false, null, false, array( 'favi_icon' ), 'no', $params ) );
1988 while ( $websites && ( $website = MainWP_DB::fetch_object( $websites ) ) && '' !== $website->sync_errors ) {
1989 $ret = false;
1990 try {
1991 $ret = MainWP_Manage_Sites_View::m_reconnect_site( $website );
1992 } catch ( \Exception $e ) {
1993 // failed.
1994 }
1995 $data[ $website->id ] = $ret ? 'success' : 'failed';
1996 }
1997 MainWP_DB::free_result( $websites );
1998 return rest_ensure_response(
1999 array(
2000 'total' => count( $data ),
2001 'data' => $data,
2002 )
2003 );
2004 }
2005
2006
2007 /**
2008 * Disconnect site.
2009 *
2010 * @param WP_REST_Request $request Full details about the request.
2011 *
2012 * @return array|mixed Response data, ready for insertion into collection data.
2013 */
2014 public function disconnect_site( $request ) {
2015
2016 $website = $this->get_request_item( $request );
2017 if ( is_wp_error( $website ) ) {
2018 return $website;
2019 }
2020
2021 $data = $this->filter_response_data_by_allowed_fields( $website, 'simple_view' );
2022
2023 $success = 0;
2024 $error = '';
2025 try {
2026 $info = MainWP_Connect::fetch_url_authed( $website, 'disconnect' );
2027 $success = is_array( $info ) && isset( $info['result'] ) && 'success' === $info['result'] ? 1 : 0;
2028 $error = is_array( $info ) && ! empty( $info['error'] ) ? $info['error'] : '';
2029 } catch ( \Exception $e ) {
2030 /* translators: %d: Site ID */
2031 return new \WP_Error( 'mainwp_rest_disconnect_site_error', sprintf( esc_html__( 'Disconnect Site "%d" error:', 'mainwp' ), $website->id ) . ': ' . $e->getMessage() );
2032 }
2033 $resp_data = array(
2034 'success' => $success,
2035 'data' => $data,
2036 );
2037 if ( ! empty( $error ) ) {
2038 $resp_data['error'] = $error;
2039 }
2040 return rest_ensure_response( $resp_data );
2041 }
2042
2043
2044 /**
2045 * Suspend site.
2046 *
2047 * @param WP_REST_Request $request Full details about the request.
2048 *
2049 * @return array|mixed Response data, ready for insertion into collection data.
2050 */
2051 public function suspend_item( $request ) {
2052 $website = $this->get_request_item( $request );
2053 if ( is_wp_error( $website ) ) {
2054 return $website;
2055 }
2056 $suspended = 1;
2057 if ( $website && ! $website->suspended ) { //phpcs:ignore -- to valid.
2058 $newValues = array(
2059 'suspended' => $suspended,
2060 );
2061
2062 MainWP_DB::instance()->update_website_values( $website->id, $newValues );
2063 /**
2064 * Fires immediately after website suspended/unsuspend.
2065 *
2066 * @since 4.5.2
2067 *
2068 * @param object $website website data.
2069 * @param int $suspended The new suspended value.
2070 */
2071 do_action( 'mainwp_site_suspended', $website, $suspended );
2072 }
2073
2074 $resp_data = array(
2075 'success' => 1,
2076 'suspended' => $suspended,
2077 'data' => $this->filter_response_data_by_allowed_fields( $website, 'simple_view' ),
2078 );
2079
2080 return rest_ensure_response( $resp_data );
2081 }
2082
2083
2084 /**
2085 * Unsuspend site.
2086 *
2087 * @param WP_REST_Request $request Full details about the request.
2088 *
2089 * @return array|mixed Response data, ready for insertion into collection data.
2090 */
2091 public function unsuspend_item( $request ) {
2092 $website = $this->get_request_item( $request );
2093 if ( is_wp_error( $website ) ) {
2094 return $website;
2095 }
2096 $suspended = 0;
2097 if ( $website && $website->suspended ) { //phpcs:ignore -- to valid.
2098 $newValues = array(
2099 'suspended' => $suspended,
2100 );
2101
2102 MainWP_DB::instance()->update_website_values( $website->id, $newValues );
2103 /**
2104 * Fires immediately after website suspended/unsuspend.
2105 *
2106 * @since 4.5.2
2107 *
2108 * @param object $website website data.
2109 * @param int $suspended The new suspended value.
2110 */
2111 do_action( 'mainwp_site_suspended', $website, $suspended );
2112 }
2113 $resp_data = array(
2114 'success' => 1,
2115 'suspended' => $suspended,
2116 'data' => $this->filter_response_data_by_allowed_fields( $website, 'simple_view' ),
2117 );
2118 return rest_ensure_response( $resp_data );
2119 }
2120
2121 /**
2122 * Disconnect sites.
2123 *
2124 * @param WP_REST_Request $request Full details about the request.
2125 *
2126 * @return WP_Error|WP_REST_Response
2127 */
2128 public function disconnect_sites( $request ) {
2129
2130 $args = $this->prepare_objects_query( $request );
2131 $params = array(
2132 'view' => 'base_view',
2133 'exclude' => isset( $args['exclude'] ) && ! empty( $args['exclude'] ) ? $args['exclude'] : array(),
2134 'include' => isset( $args['include'] ) && ! empty( $args['include'] ) ? $args['include'] : array(),
2135 );
2136 $data = array();
2137 $websites = MainWP_DB::instance()->query( MainWP_DB::instance()->get_sql_websites_for_current_user_by_params( $params ) );
2138 while ( $websites && ( $website = MainWP_DB::fetch_object( $websites ) ) ) {
2139 $info = false;
2140 try {
2141 $info = MainWP_Connect::fetch_url_authed( $website, 'disconnect' );
2142 } catch ( \Exception $e ) {
2143 // ok.
2144 }
2145 $success = 0;
2146 if ( is_array( $info ) ) {
2147 $success = isset( $info['result'] ) ? 1 : 0;
2148 }
2149 $data[ $website->id ] = $success ? 'success' : 'failed';
2150 }
2151 MainWP_DB::free_result( $websites );
2152 return rest_ensure_response(
2153 array(
2154 'total' => count( $data ),
2155 'data' => $data,
2156 )
2157 );
2158 }
2159
2160 /**
2161 * Check site.
2162 *
2163 * @param WP_REST_Request $request Full details about the request.
2164 *
2165 * @return WP_Error|WP_REST_Response
2166 */
2167 public function check_item( $request ) {
2168
2169 $website = $this->get_request_item( $request );
2170 if ( is_wp_error( $website ) ) {
2171 return $website;
2172 }
2173
2174 $error = '';
2175 try {
2176 $ret = MainWP_Monitoring_Handler::handle_check_website( $website );
2177 } catch ( \Exception $e ) {
2178 $ret = false;
2179 $error = $e->getMessage();
2180 }
2181
2182 $resp_data = array( 'success' => $ret ? 1 : 0 );
2183 $new_code = is_array( $ret ) && isset( $ret['httpCode'] ) ? $ret['httpCode'] : false;
2184
2185 if ( ! empty( $error ) ) {
2186 $resp_data ['error'] = esc_html( $result );
2187 }
2188
2189 $data = $this->filter_response_data_by_allowed_fields( $website, 'simple_view' );
2190
2191 if ( false !== $new_code ) {
2192 if ( is_array( $result ) && isset( $result['new_uptime_status'] ) ) {
2193 $data['status'] = $result['new_uptime_status'];
2194 } else {
2195 $data['status'] = MainWP_Monitoring_Handler::get_site_checking_status( $new_code );
2196 }
2197 $data['http_code'] = $new_code;
2198 }
2199
2200 $resp_data['data'] = $data;
2201
2202 return rest_ensure_response( $resp_data );
2203 }
2204
2205 /**
2206 * Check sites.
2207 *
2208 * @param WP_REST_Request $request Full details about the request.
2209 *
2210 * @return WP_Error|WP_REST_Response
2211 */
2212 public function check_items( $request ) {
2213 $args = $this->prepare_objects_query( $request );
2214 $params = array(
2215 'exclude' => isset( $args['exclude'] ) && ! empty( $args['exclude'] ) ? $args['exclude'] : array(),
2216 'include' => isset( $args['include'] ) && ! empty( $args['include'] ) ? $args['include'] : array(),
2217 );
2218 $data = array();
2219 $websites = MainWP_DB::instance()->query( MainWP_DB::instance()->get_sql_websites_for_current_user( false, null, 'wp.url', false, false, null, false, array( 'favi_icon' ), 'no', $params ) );
2220 while ( $websites && ( $website = MainWP_DB::fetch_object( $websites ) ) ) {
2221 try {
2222 $ret = MainWP_Monitoring_Handler::handle_check_website( $website );
2223 } catch ( \Exception $e ) {
2224 $ret = false;
2225 }
2226 $item = $this->prepare_site_item_for_response_context( $website, $request, 'view' );
2227 $data[ $website->id ] = array_merge( array( 'result' => $ret ? 'success' : 'failed' ), $item );
2228 }
2229 MainWP_DB::free_result( $websites );
2230 return rest_ensure_response(
2231 array(
2232 'total' => count( $data ),
2233 'data' => $data,
2234 )
2235 );
2236 }
2237
2238 /**
2239 * Remove site.
2240 *
2241 * @param WP_REST_Request $request Full details about the request.
2242 *
2243 * @return WP_Error|WP_REST_Response
2244 */
2245 public function delete_item( $request ) {
2246
2247 $website = $this->get_request_item( $request );
2248
2249 if ( is_wp_error( $website ) ) {
2250 return $website;
2251 }
2252
2253 $error = '';
2254 $result = false;
2255
2256 try {
2257 $result = MainWP_Manage_Sites_Handler::remove_wp_site( $website->id );
2258 } catch ( MainWP_Extra_Exception $e ) {
2259 $error = $e->getMessage();
2260 }
2261
2262 $success = ( is_array( $result ) && isset( $result['result'] ) && ( 'success' === $result['result'] || 'removed' === $result['result'] ) ) ? 1 : 0;
2263
2264 $resp_data = array(
2265 'success' => $success,
2266 );
2267
2268 if ( is_array( $result ) && ! empty( $result['result'] ) ) {
2269 if ( 'success' === $result['result'] ) {
2270 $message = esc_html__( 'The site has been removed and the MainWP Child plugin has been disabled.', 'mainwp' );
2271 } else {
2272 $message = esc_html__( 'The site has been removed. Please make sure that the MainWP Child plugin has been deactivated properly.', 'mainwp' );
2273 }
2274 $resp_data['message'] = $message;
2275 }
2276
2277 if ( ! empty( $error ) ) {
2278 $resp_data['error'] = $error;
2279 }
2280
2281 $resp_data['data'] = $this->filter_response_data_by_allowed_fields( $website, 'simple_view' );
2282
2283 return rest_ensure_response( $resp_data );
2284 }
2285
2286 /**
2287 * Get client of site.
2288 *
2289 * @param WP_REST_Request $request Full details about the request.
2290 *
2291 * @return WP_Error|WP_REST_Response
2292 */
2293 public function get_client_site( $request ) {
2294 $website = $this->get_request_item( $request );
2295
2296 if ( is_wp_error( $website ) ) {
2297 return $website;
2298 }
2299
2300 $client = false;
2301 if ( $website->client_id ) {
2302 $client = MainWP_DB_Client::instance()->get_wp_client_by( 'client_id', $website->client_id );
2303 }
2304
2305 $resp_data = array(
2306 'success' => $client ? 1 : 0,
2307 );
2308
2309 if ( empty( $client ) ) {
2310 $resp_data['error'] = esc_html__( 'Client is not set or not found.', 'mainwp' );
2311 } else {
2312 $resp_data['data'] = $client;
2313 }
2314
2315 return rest_ensure_response( $resp_data );
2316 }
2317
2318 /**
2319 * Get costs of site.
2320 *
2321 * @param WP_REST_Request $request Full details about the request.
2322 *
2323 * @return WP_Error|WP_REST_Response
2324 */
2325 public function get_costs_site( $request ) {
2326 $website = $this->get_request_item( $request );
2327 if ( is_wp_error( $website ) ) {
2328 return $website;
2329 }
2330 $resp_data = array(
2331 'success' => 1,
2332 );
2333 $costs = Cost_Tracker_DB::get_instance()->get_cost_tracker_by( 'site_id', $website->id );
2334 if ( $costs ) {
2335 $data = Cost_Tracker_Rest_Api_Handle_V1::instance()->prepare_api_costs_data( $costs );
2336 $resp_data['total'] = is_array( $data ) ? count( $data ) : 0;
2337 $resp_data['data'] = $data;
2338 } else {
2339 $resp_data['message'] = esc_html__( 'Cost not found.', 'mainwp' );
2340 $resp_data['data'] = array();
2341 }
2342 return rest_ensure_response( $resp_data );
2343 }
2344
2345 /**
2346 * Get site by.
2347 *
2348 * @param WP_REST_Request $request Full details about the request.
2349 *
2350 * @return WP_Error|Object Item.
2351 */
2352 public function get_request_item( $request ) {
2353 return $this->get_site_item( $request );
2354 }
2355
2356
2357 /**
2358 * Get the location schema, conforming to JSON Schema.
2359 *
2360 * @since 3.5.0
2361 * @return array
2362 */
2363 public function get_item_schema() { //phpcs:ignore -- NOSONAR - long function.
2364 return array(
2365 '$schema' => 'http://json-schema.org/draft-04/schema#',
2366 'title' => 'sites',
2367 'type' => 'object',
2368 'properties' => array(
2369 'id' => array(
2370 'type' => 'integer',
2371 'description' => __( 'Site ID.', 'mainwp' ),
2372 'context' => array( 'view', 'edit', 'simple_view' ),
2373 'arg_options' => array(
2374 'sanitize_callback' => 'wp_parse_id_list',
2375 ),
2376 ),
2377 'name' => array(
2378 'type' => 'string',
2379 'description' => __( 'Site name.', 'mainwp' ),
2380 'context' => array( 'view', 'edit', 'simple_view' ),
2381 'arg_options' => array(
2382 'sanitize_callback' => 'sanitize_text_field',
2383 ),
2384 ),
2385 'url' => array(
2386 'type' => 'string',
2387 'description' => __( 'Site url.', 'mainwp' ),
2388 'context' => array( 'view', 'edit', 'simple_view' ),
2389 'arg_options' => array(
2390 'sanitize_callback' => 'sanitize_text_field',
2391 ),
2392 ),
2393 'tags' => array(
2394 'type' => 'string',
2395 'description' => __( 'Site tags.', 'mainwp' ),
2396 'context' => array( 'view' ),
2397 ),
2398 'note' => array(
2399 'type' => 'string',
2400 'description' => __( 'Site note.', 'mainwp' ),
2401 'context' => array( 'view', 'edit' ),
2402 ),
2403 'ip' => array(
2404 'type' => 'string',
2405 'description' => __( 'Site IP.', 'mainwp' ),
2406 'context' => array( 'view' ),
2407 'arg_options' => array(
2408 'sanitize_callback' => 'sanitize_text_field',
2409 ),
2410 ),
2411 'client_id' => array(
2412 'type' => 'integer',
2413 'default' => 0,
2414 'description' => __( 'Client id', 'mainwp' ),
2415 'context' => array( 'view', 'edit', 'simple_view' ),
2416 ),
2417 'is_staging' => array(
2418 'type' => 'integer',
2419 'default' => 0,
2420 'description' => __( 'is staging', 'mainwp' ),
2421 'context' => array( 'view' ),
2422 ),
2423 'wp_version' => array(
2424 'type' => 'string',
2425 'default' => '',
2426 'description' => __( 'wp version', 'mainwp' ),
2427 'context' => array( 'view' ),
2428 ),
2429 'php_version' => array(
2430 'type' => 'string',
2431 'default' => '',
2432 'description' => __( 'php version', 'mainwp' ),
2433 'context' => array( 'view' ),
2434 ),
2435 'child_version' => array(
2436 'type' => 'string',
2437 'default' => '',
2438 'description' => __( 'child version', 'mainwp' ),
2439 'context' => array( 'view' ),
2440 ),
2441 'mysql_version' => array(
2442 'type' => 'string',
2443 'default' => '',
2444 'description' => __( 'mysql version', 'mainwp' ),
2445 'context' => array( 'view' ),
2446 ),
2447 'memory_limit' => array(
2448 'type' => 'string',
2449 'default' => '',
2450 'description' => __( 'memory limit', 'mainwp' ),
2451 'context' => array( 'view' ),
2452 ),
2453 'database_size' => array(
2454 'type' => 'string',
2455 'default' => '',
2456 'description' => __( 'Database size', 'mainwp' ),
2457 'context' => array( 'view' ),
2458 ),
2459 'active_theme' => array(
2460 'type' => 'string',
2461 'default' => '',
2462 'description' => __( 'Active theme', 'mainwp' ),
2463 'context' => array( 'view' ),
2464 ),
2465 'status' => array(
2466 'type' => array( 'string', 'array' ),
2467 'default' => '',
2468 'description' => __( 'Status', 'mainwp' ),
2469 'context' => array( 'view' ),
2470 ),
2471 'sync_errors' => array(
2472 'type' => 'string',
2473 'default' => '',
2474 'description' => __( 'Sync errors', 'mainwp' ),
2475 'context' => array( 'view' ),
2476 ),
2477 'http_status' => array(
2478 'type' => 'string',
2479 'default' => '',
2480 'description' => __( 'Http status', 'mainwp' ),
2481 'context' => array( 'view' ),
2482 ),
2483 'health_status' => array(
2484 'type' => 'string',
2485 'default' => '',
2486 'description' => __( 'Health status', 'mainwp' ),
2487 'context' => array( 'view' ),
2488 ),
2489 'health_score' => array(
2490 'type' => 'string',
2491 'default' => '',
2492 'description' => __( 'Health score', 'mainwp' ),
2493 'context' => array( 'view' ),
2494 ),
2495 'icon' => array(
2496 'type' => 'string',
2497 'default' => '',
2498 'description' => __( 'Icon', 'mainwp' ),
2499 'context' => array( 'view' ),
2500 ),
2501 'last_sync' => array(
2502 'type' => 'string',
2503 'default' => '',
2504 'description' => __( 'Last sync', 'mainwp' ),
2505 'context' => array( 'view' ),
2506 ),
2507 'plugins' => array(
2508 'type' => array( 'string', 'array' ),
2509 'default' => '',
2510 'description' => __( 'Plugins', 'mainwp' ),
2511 'context' => array( 'view' ),
2512 ),
2513 'themes' => array(
2514 'type' => array( 'string', 'array' ),
2515 'default' => '',
2516 'description' => __( 'Themes', 'mainwp' ),
2517 'context' => array( 'view' ),
2518 ),
2519 'last_post_time' => array(
2520 'type' => 'string',
2521 'default' => '',
2522 'description' => __( 'Last post time', 'mainwp' ),
2523 'context' => array( 'view' ),
2524 ),
2525 'recent_posts' => array(
2526 'type' => 'string',
2527 'default' => '',
2528 'description' => __( 'Recent posts', 'mainwp' ),
2529 'context' => array( 'view' ),
2530 ),
2531 'recent_pages' => array(
2532 'type' => 'string',
2533 'default' => '',
2534 'description' => __( 'Recent pages', 'mainwp' ),
2535 'context' => array( 'view' ),
2536 ),
2537 'recent_comments' => array(
2538 'type' => 'string',
2539 'default' => '',
2540 'description' => __( 'Recent comments', 'mainwp' ),
2541 'context' => array( 'view' ),
2542 ),
2543 'categories' => array(
2544 'type' => 'string',
2545 'default' => '',
2546 'description' => __( 'Categories', 'mainwp' ),
2547 'context' => array( 'view' ),
2548 ),
2549 'wp_upgrades' => array(
2550 'type' => 'string',
2551 'default' => '',
2552 'context' => array( 'view' ),
2553 ),
2554 'plugin_upgrades' => array(
2555 'type' => 'string',
2556 'default' => '',
2557 'context' => array( 'view' ),
2558 ),
2559 'premium_upgrades' => array(
2560 'type' => 'string',
2561 'default' => '',
2562 'context' => array( 'view' ),
2563 ),
2564 'theme_upgrades' => array(
2565 'type' => 'string',
2566 'default' => '',
2567 'context' => array( 'view' ),
2568 ),
2569 'translation_upgrades' => array(
2570 'type' => 'string',
2571 'default' => '',
2572 'context' => array( 'view' ),
2573 ),
2574 'ignored_plugins' => array(
2575 'type' => 'string',
2576 'default' => '',
2577 'context' => array( 'view' ),
2578 ),
2579 'ignored_themes' => array(
2580 'type' => 'string',
2581 'default' => '',
2582 'context' => array( 'view' ),
2583 ),
2584 'automatic_update' => array(
2585 'type' => 'integer',
2586 'default' => 0,
2587 'description' => __( 'Automatic update', 'mainwp' ),
2588 'context' => array( 'edit' ),
2589 'enum' => array( 0, 1 ),
2590 'arg_options' => array(
2591 'sanitize_callback' => 'absint',
2592 ),
2593 ),
2594 // MWP-1541: Basic Auth credentials were exposed in the default
2595 // 'view' context, leaking http_pass to any v2 read-key holder.
2596 // Narrowed to 'edit'-only as the formal contract; the actual
2597 // protection on read responses is provided by
2598 // strip_never_in_response_fields() in
2599 // prepare_site_item_for_response_context(), which strips these
2600 // fields from every site response regardless of context or
2601 // _fields. check_permissions() in the auth layer does not
2602 // currently gate ?context=edit to actual edit-permission keys,
2603 // and no caller forwards request context to the schema-filter
2604 // step, so the schema label alone is not the effective guard.
2605 'http_user' => array(
2606 'type' => 'string',
2607 'description' => __( 'HTTP user', 'mainwp' ),
2608 'context' => array( 'edit' ),
2609 'arg_options' => array(
2610 'sanitize_callback' => 'sanitize_text_field',
2611 ),
2612 ),
2613 'http_pass' => array(
2614 'type' => 'string',
2615 'description' => __( 'HTTP password', 'mainwp' ),
2616 'context' => array( 'edit' ),
2617 ),
2618 // uniqueId is the per-site secure-mode signing identifier; same
2619 // narrowing as http_pass / http_user. Unlike them, it was already
2620 // view-only (never accepted as input) so this change strictly
2621 // tightens the surface.
2622 'uniqueId' => array(
2623 'type' => 'string',
2624 'default' => '',
2625 'context' => array( 'edit' ),
2626 ),
2627 'disable_health_check' => array(
2628 'type' => 'integer',
2629 'default' => 0,
2630 'description' => __( 'Disable health check', 'mainwp' ),
2631 'context' => array( 'edit' ),
2632 'enum' => array( 0, 1 ),
2633 'arg_options' => array(
2634 'sanitize_callback' => 'absint',
2635 ),
2636 ),
2637 'health_threshold' => array(
2638 'type' => 'integer',
2639 'default' => 0,
2640 'description' => __( 'Health threshold', 'mainwp' ),
2641 'context' => array( 'edit' ),
2642 'arg_options' => array(
2643 'sanitize_callback' => 'absint',
2644 ),
2645 ),
2646 'is_ignoreCoreUpdates' => array(
2647 'type' => 'string',
2648 'default' => '',
2649 'context' => array( 'view' ),
2650 ),
2651 'is_ignorePluginUpdates' => array(
2652 'type' => 'string',
2653 'default' => '',
2654 'context' => array( 'view' ),
2655 ),
2656 'is_ignoreThemeUpdates' => array(
2657 'type' => 'string',
2658 'default' => '',
2659 'context' => array( 'view' ),
2660 ),
2661 'verify_certificate' => array(
2662 'type' => 'string',
2663 'default' => '',
2664 'context' => array( 'view' ),
2665 ),
2666 'force_use_ipv4' => array(
2667 'type' => 'string',
2668 'default' => '',
2669 'context' => array( 'view', 'edit' ),
2670 ),
2671 'ssl_version' => array(
2672 'type' => 'string',
2673 'default' => '',
2674 'context' => array( 'view' ),
2675 ),
2676 'suspended' => array(
2677 'type' => 'integer',
2678 'default' => 0,
2679 'description' => __( 'Suspended', 'mainwp' ),
2680 'context' => array( 'view', 'edit' ),
2681 'enum' => array( 0, 1 ),
2682 'arg_options' => array(
2683 'sanitize_callback' => 'absint',
2684 ),
2685 ),
2686 // groupids is validated and sanitized in the database save function
2687 // before updating the group associations.
2688 'groupids' => array(
2689 'type' => 'string',
2690 'description' => __( 'Site tag/group IDs as a comma-separated string.', 'mainwp' ),
2691 'context' => array( 'edit' ),
2692 ),
2693
2694 ),
2695 );
2696 }
2697
2698
2699 /**
2700 * Arguments registered by the add route.
2701 *
2702 * @return array
2703 */
2704 protected function get_add_site_args() {
2705 return array_merge( $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ), $this->get_add_site_extra_args() );
2706 }
2707
2708 /**
2709 * Get the args a batch create item is validated against.
2710 *
2711 * The add route registers the editable schema args plus the add-only extras,
2712 * so a batch create item has to be held to that same set.
2713 *
2714 * @return array
2715 */
2716 public function get_batch_create_args() {
2717 return $this->get_add_site_args();
2718 }
2719
2720 /**
2721 * Get the args a batch update item is validated against.
2722 *
2723 * @return array
2724 */
2725 public function get_batch_update_args() {
2726 $args = $this->get_edit_site_args();
2727 $args['id'] = $this->get_batch_item_id_arg();
2728 return $args;
2729 }
2730
2731 /**
2732 * Arguments registered by the edit route.
2733 *
2734 * The schema's id arg is the collection filter (wp_parse_id_list) and the edit
2735 * route identifies the site by its URL segment, so id is dropped here. Schema
2736 * defaults are dropped too: core injects arg defaults as request params, and
2737 * prepare_object_for_update() treats every present field as a change, so a
2738 * name-only edit would otherwise also write suspended = 0 and the update flags.
2739 *
2740 * @return array
2741 */
2742 protected function get_edit_site_args() {
2743 $args = $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE );
2744 unset( $args['id'] );
2745 foreach ( $args as &$arg ) {
2746 unset( $arg['default'] );
2747 }
2748 unset( $arg );
2749 return array_merge( $args, $this->get_edit_site_extra_args() );
2750 }
2751
2752 /**
2753 * Extra arguments accepted by the edit route.
2754 *
2755 * These are the documented update-only input spellings that prepare_object_for_update()
2756 * reads. They are absent from the item schema, so core never type-checked them.
2757 *
2758 * @return array
2759 */
2760 protected function get_edit_site_extra_args() {
2761 $args = array(
2762 'adminname' => array(
2763 'description' => __( 'Site administrator username.', 'mainwp' ),
2764 'type' => 'string',
2765 ),
2766 'admin' => array(
2767 'description' => __( 'Site administrator username, accepted alias of adminname.', 'mainwp' ),
2768 'type' => 'string',
2769 ),
2770 'uniqueid' => array(
2771 'description' => __( 'Unique security ID.', 'mainwp' ),
2772 'type' => 'string',
2773 ),
2774 'groupids' => array(
2775 'description' => __( 'Comma-separated tag IDs.', 'mainwp' ),
2776 'type' => 'string',
2777 ),
2778 'protocol' => array(
2779 'description' => __( 'Site protocol.', 'mainwp' ),
2780 'type' => 'string',
2781 'enum' => array( 'http', 'https' ),
2782 ),
2783 'sslversion' => array(
2784 'description' => __( 'SSL version.', 'mainwp' ),
2785 'type' => 'string',
2786 ),
2787 'disablehealthchecking' => array(
2788 'description' => __( 'Disable health check.', 'mainwp' ),
2789 'type' => 'integer',
2790 'enum' => array( 0, 1 ),
2791 ),
2792 'healththreshold' => array(
2793 'description' => __( 'Health threshold.', 'mainwp' ),
2794 'type' => 'integer',
2795 ),
2796 'backup_before_upgrade' => array(
2797 'description' => __( 'Backup before upgrade.', 'mainwp' ),
2798 'type' => 'integer',
2799 'enum' => array( 0, 1 ),
2800 ),
2801 'ignore_core_updates' => array(
2802 'description' => __( 'Ignore WordPress core updates.', 'mainwp' ),
2803 'type' => 'integer',
2804 'enum' => array( 0, 1 ),
2805 ),
2806 'ignore_plugin_updates' => array(
2807 'description' => __( 'Ignore plugin updates.', 'mainwp' ),
2808 'type' => 'integer',
2809 'enum' => array( 0, 1 ),
2810 ),
2811 'ignore_theme_updates' => array(
2812 'description' => __( 'Ignore theme updates.', 'mainwp' ),
2813 'type' => 'integer',
2814 'enum' => array( 0, 1 ),
2815 ),
2816 'monitoring_emails' => array(
2817 'description' => __( 'Monitoring notification email addresses.', 'mainwp' ),
2818 'type' => 'string',
2819 ),
2820 'force_use_ipv4' => $this->get_force_use_ipv4_arg(),
2821 );
2822
2823 // The item schema types the is_ignore* columns as view strings, and the DB layer
2824 // tests them for truthiness, so left as strings "false" would enable ignoring.
2825 $aliases = array(
2826 'is_ignoreCoreUpdates' => 'ignore_core_updates',
2827 'is_ignorePluginUpdates' => 'ignore_plugin_updates',
2828 'is_ignoreThemeUpdates' => 'ignore_theme_updates',
2829 );
2830 foreach ( $aliases as $alias => $documented ) {
2831 $args[ $alias ] = array(
2832 'description' => $args[ $documented ]['description'],
2833 'type' => 'integer',
2834 'enum' => array( 0, 1 ),
2835 );
2836 }
2837
2838 return $args;
2839 }
2840
2841 /**
2842 * Extra arguments accepted by the add route.
2843 *
2844 * These are consumed by prepare_object_for_database() but absent from
2845 * the item schema, so core never type-checked them and an array value
2846 * reached the string sanitizers and fataled the request. Registering them
2847 * here types them without touching the published item schema.
2848 *
2849 * @return array
2850 */
2851 protected function get_add_site_extra_args() {
2852 return array(
2853 'admin' => array(
2854 'description' => __( 'Site administrator username.', 'mainwp' ),
2855 'type' => 'string',
2856 ),
2857 'adminpassword' => array(
2858 'description' => __( 'Site administrator password.', 'mainwp' ),
2859 'type' => 'string',
2860 ),
2861 'uniqueid' => array(
2862 'description' => __( 'Unique security ID.', 'mainwp' ),
2863 'type' => 'string',
2864 ),
2865 'groupids' => array(
2866 'description' => __( 'Comma-separated tag IDs.', 'mainwp' ),
2867 'type' => 'string',
2868 ),
2869 'ssl_verify' => array(
2870 'description' => __( 'Verify the SSL certificate of the child site.', 'mainwp' ),
2871 'type' => 'boolean',
2872 // rest_parse_request_arg validates before it sanitizes, so an explicit
2873 // JSON null is a 400 instead of being coerced to false past the validator.
2874 'sanitize_callback' => 'rest_parse_request_arg',
2875 'validate_callback' => 'rest_validate_request_arg',
2876 ),
2877 'force_use_ipv4' => $this->get_force_use_ipv4_arg(),
2878 );
2879 }
2880
2881 /**
2882 * The force_use_ipv4 argument the two input routes accept.
2883 *
2884 * The item schema types it as a view string, so a JSON boolean was refused even
2885 * though OpenAPI documents one. The type order decides how core coerces the value:
2886 * true / "1" / "0" match boolean first, while 2 and "2" fall through to integer and
2887 * keep the "use the global setting" value the DB layer stores.
2888 *
2889 * @return array
2890 */
2891 protected function get_force_use_ipv4_arg() {
2892 return array(
2893 'description' => __( 'Force IPv4: 0 to disable, 1 to force IPv4, 2 to use the global setting.', 'mainwp' ),
2894 'type' => array( 'boolean', 'integer', 'string' ),
2895 // The DB layer only clamps values above 2, so without the enum -1 would be
2896 // stored as sent and "yes" would silently become 0. Core compares the enum
2897 // against the coerced value, so "1" and "true" still match true.
2898 'enum' => array( false, true, 2 ),
2899 // Both callbacks re-word an enum rejection: sanitize_params() runs the
2900 // sanitizer over every parameter bag while has_valid_params() only checks the
2901 // highest-priority one, so a valid body value alongside an invalid query value
2902 // is refused by the sanitizer and would otherwise carry core's wording.
2903 // rest_validate_request_arg() does not call validate_callback itself, so
2904 // neither closure recurses.
2905 'validate_callback' => function ( $value, $request, $param ) {
2906 return $this->force_use_ipv4_enum_error( rest_validate_request_arg( $value, $request, $param ), $param );
2907 },
2908 'sanitize_callback' => function ( $value, $request, $param ) {
2909 $valid = $this->force_use_ipv4_enum_error( rest_validate_request_arg( $value, $request, $param ), $param );
2910 if ( is_wp_error( $valid ) ) {
2911 return $valid;
2912 }
2913
2914 return rest_sanitize_request_arg( $value, $request, $param );
2915 },
2916 );
2917 }
2918
2919 /**
2920 * Re-word a force_use_ipv4 enum rejection, passing anything else through.
2921 *
2922 * Core lists the enum with %l, which prints its false member as an empty string
2923 * ("is not one of , 1, and 2"), so the rejection is worded in the values the route
2924 * documents instead.
2925 *
2926 * @param true|WP_Error $result Result of validating the value.
2927 * @param string $param Parameter name.
2928 * @return true|WP_Error
2929 */
2930 private function force_use_ipv4_enum_error( $result, $param ) {
2931 if ( is_wp_error( $result ) && 'rest_not_in_enum' === $result->get_error_code() ) {
2932 return new WP_Error(
2933 'rest_not_in_enum',
2934 /* translators: %s: parameter name */
2935 sprintf( __( '%s is not one of 0, 1, or 2.', 'mainwp' ), $param ),
2936 array( 'status' => 400 )
2937 );
2938 }
2939
2940 return $result;
2941 }
2942
2943 /**
2944 * Prepare a single order for create or update.
2945 *
2946 * @throws MainWP_Rest_Data_Exception When fails to set any item.
2947 * @param WP_REST_Request $request Request object.
2948 * @return array
2949 */
2950 protected function prepare_object_for_database( $request ) {
2951 $item_fields = array();
2952 $item_fields['url'] = isset( $request['url'] ) ? sanitize_text_field( $request['url'] ) : '';
2953 $item_fields['name'] = isset( $request['name'] ) ? sanitize_text_field( $request['name'] ) : '';
2954 $item_fields['wpadmin'] = isset( $request['admin'] ) ? sanitize_text_field( $request['admin'] ) : '';
2955 $item_fields['adminpwd'] = isset( $request['adminpassword'] ) ? (string) $request['adminpassword'] : ''; // Cast only; sanitize_text_field() strips characters a password may legitimately hold.
2956 $item_fields['unique_id'] = sanitize_text_field( $request['uniqueid'] ?? $request['uniqueId'] ?? '' ); // uniqueid is the documented input spelling, uniqueId is what the item schema registers on this route.
2957 $item_fields['ssl_verify'] = empty( $request['ssl_verify'] ) ? false : intval( $request['ssl_verify'] );
2958 // Omitted stays null: fetch_url() then reads the global mainwp_forceUseIPv4 option
2959 // for the handshake, the same as the UI and the v1 add handler send, and
2960 // add_website() leaves the column at its default.
2961 $item_fields['force_use_ipv4'] = null;
2962 if ( isset( $request['force_use_ipv4'] ) ) {
2963 // The add route accepts 2 (use the global setting) and mainwp_string_to_bool()
2964 // would collapse it to 0, so keep it before the boolean conversion.
2965 if ( 2 === (int) $request['force_use_ipv4'] ) {
2966 $item_fields['force_use_ipv4'] = 2;
2967 } else {
2968 $item_fields['force_use_ipv4'] = mainwp_string_to_bool( $request['force_use_ipv4'] ) ? 1 : 0;
2969 }
2970 }
2971 $item_fields['http_user'] = isset( $request['http_user'] ) ? sanitize_text_field( $request['http_user'] ) : '';
2972 $item_fields['http_pass'] = isset( $request['http_pass'] ) ? (string) $request['http_pass'] : ''; // Cast only, same reason as adminpwd.
2973 $item_fields['groupids'] = isset( $request['groupids'] ) && ! empty( $request['groupids'] ) ? explode( ',', sanitize_text_field( $request['groupids'] ) ) : array();
2974 $item_fields['clientid'] = isset( $request['client_id'] ) && ! empty( $request['client_id'] ) ? intval( $request['client_id'] ) : 0;
2975
2976 /**
2977 * Filters an object before it is inserted via the REST API.
2978 *
2979 * @since 5.2
2980 *
2981 * @param array $item_fields Site data.
2982 * @param WP_REST_Request $request Request object.
2983 */
2984 return apply_filters( 'mainwp_rest_pre_insert_site_item', $item_fields, $request );
2985 }
2986
2987 /**
2988 * Prepare a single order for create or update.
2989 *
2990 * @param WP_REST_Request $request Request object.
2991 * @return array
2992 */
2993 protected function prepare_object_for_update( $request ) {
2994
2995 $map_fields_update = array(
2996 'http_user' => 'http_user',
2997 'http_pass' => 'http_pass',
2998 'name' => 'name',
2999 'adminname' => 'admin',
3000 'ssl_version' => 'sslversion',
3001 'uniqueId' => 'uniqueid',
3002 'protocol' => 'protocol',
3003 'disable_health_check' => 'disablehealthchecking',
3004 'health_threshold' => 'healththreshold',
3005 'suspended' => 'suspended',
3006 'groupids' => 'groupids',
3007 'automatic_update' => 'automatic_update',
3008 'backup_before_upgrade' => 'backup_before_upgrade',
3009 'force_use_ipv4' => 'force_use_ipv4',
3010 'is_ignoreCoreUpdates' => 'ignore_core_updates',
3011 'is_ignorePluginUpdates' => 'ignore_plugin_updates',
3012 'is_ignoreThemeUpdates' => 'ignore_theme_updates',
3013 'monitoring_emails' => 'monitoring_emails',
3014 );
3015
3016 $data = array();
3017 // The value side is the documented input spelling; the item-schema spelling on
3018 // the key side is accepted as a fallback, the same way prepare_object_for_database()
3019 // accepts uniqueId.
3020 foreach ( $map_fields_update as $schema_field => $field ) {
3021 if ( isset( $request[ $field ] ) ) {
3022 $data[ $field ] = $request[ $field ];
3023 } elseif ( isset( $request[ $schema_field ] ) ) {
3024 $data[ $field ] = $request[ $schema_field ];
3025 }
3026 }
3027
3028 // rest_api_update_website() writes client_id whenever the key is set, so an
3029 // unconditional entry here would unassign the client on every name-only edit.
3030 // A sent 0 stays the explicit unassign.
3031 if ( isset( $request['client_id'] ) ) {
3032 $data['client_id'] = intval( $request['client_id'] );
3033 }
3034
3035 /**
3036 * Filters an object before it is inserted via the REST API.
3037 *
3038 * @since 5.2
3039 *
3040 * @param array $item_fields Site data.
3041 * @param WP_REST_Request $request Request object.
3042 */
3043 return apply_filters( 'mainwp_rest_pre_update_site_item', $data, $request );
3044 }
3045
3046 /**
3047 * Get formatted item object.
3048 *
3049 * @since 5.2
3050 * @param object $site_object Site object.
3051 * @param array $args Request args.
3052 *
3053 * @return array
3054 */
3055 protected function get_pre_formatted_item_data( $site_object, $args = array() ) {
3056
3057 $fields = false;
3058 // Determine if the response fields were specified.
3059 if ( ! empty( $this->request['_fields'] ) ) {
3060 $fields = wp_parse_list( $this->request['_fields'] );
3061
3062 if ( 0 === count( $fields ) ) {
3063 $fields = false;
3064 } else {
3065 $fields = array_map( 'trim', $fields );
3066 }
3067 }
3068
3069 $extra_fields = array();
3070 $extra_fields = false === $fields ? $extra_fields : array_intersect( $extra_fields, $fields );
3071
3072 $formatted_data = array(
3073 'id' => $site_object->id,
3074 'url' => $site_object->url,
3075 'name' => $site_object->name,
3076 'client_id' => $site_object->client_id,
3077 );
3078
3079 if ( ! empty( $args['selectgroups'] ) ) {
3080 $formatted_data['wpgroups'] = $site_object->wpgroups;
3081 }
3082
3083 if ( ! empty( $args['full_data'] ) ) {
3084 $formatted_extra = array(
3085 'id' => $site_object->id,
3086 'url' => $site_object->url,
3087 'name' => $site_object->name,
3088 'offline_checks_last' => $site_object->offline_checks_last,
3089 'offline_check_result' => $site_object->offline_check_result, // 1 - online, -1 offline.
3090 'http_response_code' => $site_object->http_response_code,
3091 'disable_health_check' => $site_object->disable_health_check,
3092 'health_threshold' => $site_object->health_threshold,
3093 'note' => $site_object->note,
3094 'plugin_upgrades' => $site_object->plugin_upgrades,
3095 'theme_upgrades' => $site_object->theme_upgrades,
3096 'translation_upgrades' => $site_object->translation_upgrades,
3097 'securityIssues' => $site_object->securityIssues,
3098 'themes' => $site_object->themes,
3099 'plugins' => $site_object->plugins,
3100 'automatic_update' => ! empty( $site_object->automatic_update ) ? $site_object->automatic_update : 0,
3101 'sync_errors' => $site_object->sync_errors,
3102 'last_post_gmt' => $site_object->last_post_gmt,
3103 'health_value' => $site_object->health_value,
3104 'phpversion' => $site_object->phpversion,
3105 'wp_upgrades' => $site_object->wp_upgrades,
3106 'security_stats' => $site_object->security_stats,
3107 'client_id' => $site_object->client_id,
3108 'adminname' => $site_object->adminname,
3109 'http_user' => $site_object->http_user,
3110 'http_pass' => $site_object->http_pass,
3111 'ssl_version' => $site_object->ssl_version,
3112 'signature_algo' => $site_object->signature_algo,
3113 'verify_method' => $site_object->verify_method,
3114 'verify_certificate' => $site_object->verify_certificate,
3115 );
3116
3117 $format_date = array( 'dtsAutomaticSync', 'dtsAutomaticSyncStart', 'dtsSync', 'dtsSyncStart' );
3118 // Format date values.
3119 foreach ( $format_date as $key ) {
3120 // Date created is stored UTC, date modified is stored WP local time.
3121 $datetime = get_date_from_gmt( gmdate( 'Y-m-d H:i:s', $site_object->$key ) );
3122 $formatted_extra[ $key ] = mainwp_rest_prepare_date_response( $datetime, false );
3123 }
3124
3125 $formatted_data = array_merge( $formatted_data, $formatted_extra );
3126 }
3127
3128 return $formatted_data;
3129 }
3130
3131 /**
3132 * Prepare a single product review output for response.
3133 *
3134 * @param object $item site object.
3135 * @param WP_REST_Request $request Request object.
3136 * @param array $args args.
3137 *
3138 * @return WP_REST_Response $response Response data.
3139 */
3140 public function prepare_item_for_response( $item, $request, $args = array() ) { //phpcs:ignore -- NOSONAR - complex.
3141
3142 if ( ! is_object( $item ) ) {
3143 $item = new \stdClass();
3144 }
3145
3146 $fields = $this->get_fields_for_response( $request );
3147
3148 if ( ! is_array( $fields ) ) {
3149 $fields = array();
3150 }
3151
3152 $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
3153
3154 $data = $this->filter_response_by_context( $item, $context );
3155
3156 if ( is_object( $data ) ) {
3157 $_data = array();
3158 foreach ( $data as $field => $value ) {
3159 $_data[ $field ] = $value;
3160 }
3161 $data = $_data;
3162 }
3163
3164 if ( in_array( 'tags', $fields ) && property_exists( $item, 'wpgroups' ) && property_exists( $item, 'wpgroupids' ) ) {
3165 $wpgroupids = ! empty( $item->wpgroupids ) ? explode( ',', $item->wpgroupids ) : array();
3166 $wpgroups = ! empty( $item->wpgroups ) ? explode( ',', $item->wpgroups ) : array();
3167
3168 $tags = array();
3169 if ( is_array( $wpgroupids ) ) {
3170 foreach ( $wpgroupids as $gidx => $groupid ) {
3171 if ( $groupid && ! isset( $tags[ $groupid ] ) ) {
3172 $tags[ $groupid ] = isset( $wpgroups[ $gidx ] ) ? $wpgroups[ $gidx ] : '';
3173 }
3174 }
3175 }
3176 $data['tags'] = $tags;
3177 }
3178
3179 $map_fields = array(
3180 'database_size' => 'dbsize',
3181 'http_status' => 'http_response_code',
3182 'icon' => 'cust_site_icon_info',
3183 'last_sync' => 'dtsSync',
3184 'last_post_time' => 'last_post_gmt',
3185 );
3186
3187 if ( is_array( $data ) ) {
3188 foreach ( $map_fields as $field1 => $field2 ) {
3189 if ( in_array( $field1, $fields ) && property_exists( $item, $field2 ) ) {
3190 $data[ $field1 ] = $item->{$field2};
3191 }
3192 }
3193
3194 if ( in_array( 'health_score', $fields, true ) ) {
3195 // health_value is a sortable composite (score - critical * 100) that goes
3196 // negative on critical issues; report the label v1 and WP-CLI derive from
3197 // the stored Site Health issue counts, not the raw column.
3198 // Passing the $item object (not $item->id) with json_format=true uses the
3199 // property fast-path when health_site_status is already on the row, and
3200 // guards scalars to array() so a malformed value cannot fatal.
3201 $health_status = MainWP_DB::instance()->get_website_option( $item, 'health_site_status', array(), true );
3202 $hstatus = MainWP_Utility::get_site_health( $health_status );
3203 $data['health_score'] = ( 80 <= $hstatus['val'] && empty( $hstatus['critical'] ) ) ? 'Good' : 'Should be improved';
3204 }
3205 }
3206
3207 $website_info = MainWP_DB::instance()->get_website_option( $item->id, 'site_info' );
3208 $website_info = ! empty( $website_info ) ? json_decode( $website_info, true ) : array();
3209
3210 if ( is_array( $website_info ) && property_exists( $item, 'http_response_code' ) ) {
3211 $code = $item->http_response_code;
3212 $code_string = MainWP_Utility::get_http_codes( $code );
3213 if ( ! empty( $code_string ) ) {
3214 $code .= ' - ' . $code_string;
3215 }
3216 $website_info['http_status'] = $code;
3217 }
3218
3219 $map_meta_fields = array(
3220 'wp_version' => 'wpversion',
3221 'php_version' => 'phpversion',
3222 'child_version' => 'child_version',
3223 'mysql_version' => 'mysql_version',
3224 'memory_limit' => 'memory_limit',
3225 'http_status' => 'http_status',
3226 );
3227
3228 foreach ( $map_meta_fields as $_field1 => $_field2 ) {
3229 if ( in_array( $_field1, $fields ) ) {
3230 $data[ $_field1 ] = isset( $website_info[ $_field2 ] ) ? $website_info[ $_field2 ] : '';
3231 }
3232 }
3233
3234 if ( in_array( 'active_theme', $fields ) ) {
3235 $active_theme = array();
3236 if ( ! empty( $item->themes ) ) {
3237 $themes = json_decode( $item->themes, 1 );
3238 if ( is_array( $themes ) && ! empty( $themes ) ) {
3239 foreach ( $themes as $theme ) {
3240 if ( ! empty( $theme['active'] ) ) {
3241 $active_theme = $theme;
3242 break;
3243 }
3244 }
3245 }
3246 }
3247 $data['active_theme'] = is_array( $active_theme ) && ! empty( $active_theme['name'] ) ? esc_html( $active_theme['name'] . ' ' . $active_theme['version'] ) : '';
3248 }
3249
3250 if ( in_array( 'status', $fields ) ) {
3251 $data['status'] = ! empty( $item->sync_errors ) ? 'disconnected' : 'connected';
3252 if ( $item->suspended ) {
3253 $data['status'] = 'suspended';
3254 }
3255 }
3256
3257 if ( is_array( $fields ) && ! empty( $fields ) ) {
3258 $_data = array();
3259 foreach ( $data as $field => $value ) {
3260 if ( in_array( $field, $fields ) ) {
3261 $_data[ $field ] = $value;
3262 }
3263 }
3264 $data = $_data;
3265 }
3266
3267 if ( isset( $data['last_sync'] ) ) {
3268 $data['last_sync'] = mainwp_rest_prepare_date_response( $data['last_sync'] );
3269 }
3270
3271 $custom_fields = $this->get_requested_custom_fields( $request );
3272
3273 if ( is_array( $custom_fields ) && ! empty( $custom_fields ) ) {
3274 foreach ( $custom_fields as $field ) {
3275 if ( property_exists( $item, $field ) && ! isset( $data[ $field ] ) ) {
3276 $data[ $field ] = $item->{$field};
3277 }
3278 }
3279 }
3280
3281 /**
3282 * Filterobject returned from the REST API.
3283 *
3284 * @param array $data The object.
3285 * @param mixed $item The object used to create response.
3286 * @param WP_REST_Request $request Request object.
3287 */
3288 return apply_filters( 'mainwp_rest_prepare_site', $data, $item, $request );
3289 }
3290
3291 /**
3292 * Add Site.
3293 *
3294 * @since 5.2
3295 * @throws Exception But all errors are validated before returning any data.
3296 *
3297 * @param WP_REST_Request $request Full details about the request.
3298 *
3299 * @return WP_Error|WP_REST_Response
3300 */
3301 public function create_item( $request ) { //phpcs:ignore -- NOSONAR - complex.
3302 $resp_data = array();
3303 $resp_data['success'] = 0;
3304 $site_id = 0;
3305 $data = array(); //phpcs:ignore -- NOSONAR - not used data.
3306 $found_id = 0;
3307 try {
3308
3309 if ( $request->has_param( 'groupids' ) && ! empty( $request['groupids'] ) && false === MainWP_DB_Common::instance()->validate_site_group_ids( $request['groupids'] ) ) {
3310 return new \WP_Error(
3311 'rest_invalid_param',
3312 __( 'The groupids parameter must be a comma-separated string of positive integers.', 'mainwp' ),
3313 array(
3314 'status' => 400,
3315 )
3316 );
3317 }
3318
3319 $item = $this->prepare_object_for_database( $request );
3320
3321 if ( empty( $item['name'] ) && ! empty( $item['url'] ) ) {
3322 $item['name'] = MainWP_Utility::remove_http_prefix( $item['url'], true );
3323 $item['name'] = rtrim( $item['name'], '/' );
3324 }
3325
3326 $website = MainWP_DB::instance()->get_websites_by_url( $item['url'] );
3327 list( $message, $error, $site_id, $found_id ) = MainWP_Manage_Sites_View::add_wp_site( $website, $item );
3328
3329 if ( ! empty( $site_id ) ) {
3330 $site = MainWP_DB::instance()->get_website_by_id( $site_id );
3331 $resp_data['success'] = 1;
3332 $resp_data['data'] = $this->filter_response_data_by_allowed_fields( $site, 'simple_view' );
3333
3334 if ( ! empty( $message ) ) {
3335 $resp_data['message'] = wp_strip_all_tags( $message );
3336 }
3337 } elseif ( ! empty( $error ) ) {
3338 $resp_data['error'] = wp_strip_all_tags( $error );
3339 } else {
3340 $resp_data['error'] = esc_html__( 'Add site failed. Please try again.', 'mainwp' );
3341 }
3342 } catch ( Exception $e ) {
3343 $resp_data['error'] = wp_strip_all_tags( $e->getMessage() );
3344 }
3345
3346 if ( ! empty( $found_id ) ) {
3347 $resp_data['found_id'] = $found_id;
3348 }
3349
3350 return rest_ensure_response( $resp_data );
3351 }
3352
3353 /**
3354 * Update site.
3355 *
3356 * @param WP_REST_Request $request Request object.
3357 *
3358 * @return WP_Error|WP_REST_Response
3359 */
3360 public function update_item( $request ) {
3361 $website = $this->get_request_item( $request );
3362 if ( is_wp_error( $website ) ) {
3363 return $website;
3364 }
3365
3366 if ( $request->has_param( 'tags' ) ) {
3367 return new \WP_Error(
3368 'rest_invalid_param',
3369 __( 'The "tags" parameter is not supported for this endpoint.', 'mainwp' ),
3370 array(
3371 'status' => 400,
3372 )
3373 );
3374 }
3375
3376 if ( $request->has_param( 'groupids' ) && ! is_string( $request['groupids'] ) ) {
3377 return new \WP_Error(
3378 'rest_invalid_param',
3379 __( 'The groupids parameter must be a string.', 'mainwp' ),
3380 array(
3381 'status' => 400,
3382 )
3383 );
3384 }
3385
3386 $resp_data = array();
3387 $resp_data['success'] = 0;
3388 try {
3389 $item = $this->prepare_object_for_update( $request );
3390 $result = MainWP_DB_Common::instance()->rest_api_update_website( $website->id, $item );
3391 if ( is_array( $result ) ) {
3392 foreach ( array( 'group_updated', 'group_message', 'message' ) as $field ) {
3393 if ( array_key_exists( $field, $result ) ) {
3394 $resp_data[ $field ] = $result[ $field ];
3395 }
3396 }
3397 }
3398 if ( is_array( $result ) && ! empty( $result['success'] ) ) {
3399 $resp_data['success'] = 1;
3400 $params = array(
3401 'full_data' => true,
3402 'selectgroups' => true,
3403 'include' => array( $website->id ),
3404 'fields' => $this->get_fields_for_response( $request ),
3405 );
3406 $websites = MainWP_DB::instance()->get_websites_for_current_user( $params );
3407 $data = $websites ? current( $websites ) : array();
3408 $resp_data['data'] = $this->prepare_site_item_for_response_context( $data, $request, 'view' );
3409 } elseif ( empty( $resp_data['group_message'] ) && empty( $resp_data['message'] ) ) {
3410 $resp_data['error'] = esc_html__( 'Update site failed. Please try again.', 'mainwp' );
3411 }
3412 } catch ( \Exception $e ) {
3413 $resp_data['error'] = $e->getMessage();
3414 }
3415 return rest_ensure_response( $resp_data );
3416 }
3417 }
3418