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
← All changes | includes/rest-api/controller/version2/class-mainwp-rest-sites-controller.php +1075 -98 5.2.1 → 6.2.2 View file →
@@ -19,8 +19,16 @@
19 19 use MainWP\Dashboard\Module\CostTracker\Cost_Tracker_Rest_Api_Handle_V1;
20 20 use MainWP\Dashboard\MainWP_DB_Common;
21 21 use MainWP\Dashboard\MainWP_Utility;
22 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 +
23 31 /**
24 32 * Class MainWP_Rest_Sites_Controller
25 33 *
26 34 * @package MainWP\Dashboard
@@ -54,10 +62,11 @@
54 62 /**
55 63 * Constructor.
56 64 */
57 65 public function __construct() {
58 - add_filter( 'mainwp_rest_routes_sites_controller_filter_allowed_fields_by_context', array( $this, 'hook_filter_allowed_fields_by_context' ), 10, 2 );
66 + add_filter( 'mainwp_rest_routes_sites_controller_filter_allowed_fields_by_context', array( $this, 'hook_filter_allowed_fields_by_context' ), 10, 3 );
59 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 );
60 69 }
61 70
62 71 /**
63 72 * Method instance()
@@ -79,13 +88,14 @@
79 88 * Get filter sites.
80 89 *
81 90 * @param object $item item data.
82 91 * @param string $context context.
92 + * @param mixed $request The rest request.
83 93 *
84 94 * @return object item in context.
85 95 */
86 - public function hook_filter_allowed_fields_by_context( $item, $context = 'view' ) {
87 - return $this->filter_response_data_by_allowed_fields( $item, $context );
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 );
88 98 }
89 99
90 100 /**
91 101 * Get filter sites.
@@ -98,8 +108,132 @@
98 108 return $this->get_allowed_fields_by_context( $context );
99 109 }
100 110
101 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 + /**
102 236 * Method register_routes()
103 237 *
104 238 * Creates the necessary endpoints for the api.
105 239 * Note, for a request to be successful the URL query parameters consumer_key and consumer_secret need to be set and correct.
@@ -235,9 +369,9 @@
235 369 array(
236 370 'methods' => WP_REST_Server::CREATABLE,
237 371 'callback' => array( $this, 'create_item' ),
238 372 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
239 - 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
373 + 'args' => $this->get_add_site_args(),
240 374 ),
241 375 )
242 376 );
243 377
@@ -248,8 +382,9 @@
248 382 array(
249 383 'methods' => WP_REST_Server::EDITABLE,
250 384 'callback' => array( $this, 'update_item' ),
251 385 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
386 + 'args' => $this->get_edit_site_args(),
252 387 ),
253 388 )
254 389 );
255 390
@@ -523,8 +658,42 @@
523 658 }
524 659
525 660
526 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 + /**
527 696 * Get item.
528 697 *
529 698 * @param WP_REST_Request $request Request object.
530 699 *
@@ -529,9 +698,9 @@
529 698 * @param WP_REST_Request $request Request object.
530 699 *
531 700 * @return array|mixed Response data, ready for insertion into collection data.
532 701 */
533 - public function get_item( $request ) {
702 + public function get_item( $request ) { // phpcs:ignore -- NOSONAR - complex method.
534 703
535 704 $value = isset( $request['id_domain'] ) ? $request['id_domain'] : '';
536 705 $by = 'domain';
537 706
@@ -540,10 +709,68 @@
540 709 } else {
541 710 $value = urldecode( $value );
542 711 }
543 712
544 - $with_tags = isset( $request['with_tags'] ) ? mainwp_string_to_bool( $request['with_tags'] ) : false;
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 );
545 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.
546 773 $prepared_args = array(
547 774 'with_tags' => $with_tags,
548 775 );
549 776
@@ -552,18 +779,27 @@
552 779 if ( is_wp_error( $item ) ) {
553 780 return $item;
554 781 }
555 782
556 - $params = array(
783 + $params = array(
557 784 'full_data' => true,
558 - 'selectgroups' => true,
785 + 'selectgroups' => $with_tags,
559 786 'include' => array( $item->id ),
560 - 'fields' => $this->get_fields_for_response( $request ),
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 ) ),
561 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 +
562 797 $websites = MainWP_DB::instance()->get_websites_for_current_user( $params );
563 798 $data = $websites ? current( $websites ) : array();
564 -
565 - $data = $this->filter_response_data_by_allowed_fields( $this->prepare_item_for_response( $data, $request ), 'view' );
799 + if ( ! empty( $data ) ) {
800 + $data = $this->prepare_site_item_for_response_context( $data, $request, 'view', $custom_fields );
801 + }
566 802 return rest_ensure_response( array( 'data' => $data ) );
567 803 }
568 804
569 805
@@ -573,9 +809,9 @@
573 809 *
574 810 * @param WP_REST_Request $request Full details about the request.
575 811 * @return WP_Error|WP_REST_Response
576 812 */
577 - public function get_items( $request ) {
813 + public function get_items( $request ) { // phpcs:ignore -- NOSONAR - complex method.
578 814
579 815 $args = $this->prepare_objects_query( $request );
580 816 $args = $this->validate_rest_args( $args, $this->get_validate_args_params( 'get_sites' ) );
581 817 if ( is_wp_error( $args ) ) {
@@ -581,10 +817,104 @@
581 817 if ( is_wp_error( $args ) ) {
582 818 return $args;
583 819 }
584 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.
585 909 $args['selectgroups'] = isset( $request['with_tags'] ) ? mainwp_string_to_bool( $request['with_tags'] ) : true;
586 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 );
587 917
588 918 // get data.
589 919 $websites = MainWP_DB::instance()->get_websites_for_current_user( $args );
590 920
@@ -591,9 +921,9 @@
591 921 $data = array();
592 922
593 923 if ( $websites ) {
594 924 foreach ( $websites as $site ) {
595 - $data[] = $this->filter_response_data_by_allowed_fields( $this->prepare_item_for_response( $site, $request ), 'view' );
925 + $data[] = $this->prepare_site_item_for_response_context( $site, $request, 'view', $custom_fields );
596 926 }
597 927 }
598 928 return rest_ensure_response(
599 929 array(
@@ -620,8 +950,15 @@
620 950 }
621 951
622 952 $args['selectgroups'] = isset( $request['with_tags'] ) ? mainwp_string_to_bool( $request['with_tags'] ) : false;
623 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 );
624 961
625 962 // get data.
626 963 $websites = MainWP_DB::instance()->get_websites_for_current_user( $args );
627 964
@@ -628,9 +965,9 @@
628 965 $data = array();
629 966
630 967 if ( $websites ) {
631 968 foreach ( $websites as $site ) {
632 - $data[] = $this->filter_response_data_by_allowed_fields( $this->prepare_item_for_response( $site, $request ), 'simple_view' );
969 + $data[] = $this->prepare_site_item_for_response_context( $site, $request, 'simple_view' );
633 970 }
634 971 }
635 972 return rest_ensure_response(
636 973 array(
@@ -688,9 +1025,9 @@
688 1025 * @param WP_REST_Request $request Full details about the request.
689 1026 *
690 1027 * @return WP_Error|WP_REST_Response
691 1028 */
692 - public function sync_item( $request ) {
1029 + public function sync_item( $request ) { // phpcs:ignore -- NOSONAR - complex.
693 1030
694 1031 $website = $this->get_request_item( $request );
695 1032
696 1033 if ( is_wp_error( $website ) ) {
@@ -696,8 +1033,57 @@
696 1033 if ( is_wp_error( $website ) ) {
697 1034 return $website;
698 1035 }
699 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.
700 1086 try {
701 1087 $success = MainWP_Sync::sync_site( $website );
702 1088 } catch ( \Exception $e ) {
703 1089 return new WP_Error( 'mainwp_rest_sync_site_error', $e->getMessage(), array( 'status' => 404 ) );
@@ -713,9 +1099,9 @@
713 1099 'fields' => $this->get_fields_for_response( $request ),
714 1100 );
715 1101 $websites = MainWP_DB::instance()->get_websites_for_current_user( $params );
716 1102 $data = $websites ? current( $websites ) : array();
717 - $data = $this->filter_response_data_by_allowed_fields( $this->prepare_item_for_response( $data, $request ), 'view' );
1103 + $data = $this->prepare_site_item_for_response_context( $data, $request, 'view' );
718 1104 }
719 1105
720 1106 return rest_ensure_response(
721 1107 array(
@@ -776,8 +1162,60 @@
776 1162 if ( is_wp_error( $args ) ) {
777 1163 return $args;
778 1164 }
779 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.
780 1218 $data = json_decode( $website->plugins, 1 );
781 1219
782 1220 if ( is_array( $data ) ) {
783 1221 if ( ! empty( $args['must_use'] ) ) {
@@ -1026,8 +1464,60 @@
1026 1464 if ( isset( $args['status'] ) ) {
1027 1465 $args['status'] = array_unique( wp_parse_list( $args['status'] ) );
1028 1466 }
1029 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.
1030 1520 $data = json_decode( $website->themes, 1 );
1031 1521 if ( is_array( $data ) ) {
1032 1522 if ( ! empty( $args['s'] ) ) {
1033 1523 $s = trim( $args['s'] );
@@ -1107,9 +1597,10 @@
1107 1597 $success = 0;
1108 1598 $data = array();
1109 1599
1110 1600 if ( 'delete' === $action && is_array( $information ) && ! empty( $information['error']['is_activated_theme'] ) ) {
1111 - $data['error'] = esc_html__( sprintf( 'The theme %s is active.', $information['error']['is_activated_theme'] ), 'mainwp' );
1601 + /* translators: %s: Theme name */
1602 + $data['error'] = sprintf( esc_html__( 'The theme %s is active.', 'mainwp' ), esc_html( $information['error']['is_activated_theme'] ) );
1112 1603 }
1113 1604
1114 1605 if ( 'activate' === $action && isset( $information['other_data']['theme_deactivate_data'] ) ) {
1115 1606 $success = 1;
@@ -1263,9 +1754,9 @@
1263 1754 * @param WP_REST_Request $request Full details about the request.
1264 1755 *
1265 1756 * @return WP_Error|WP_REST_Response
1266 1757 */
1267 - public function get_non_mainwp_changes_of_site( $request ) {
1758 + public function get_non_mainwp_changes_of_site( $request ) { //phpcs:ignore -- NOSONAR -complex.
1268 1759
1269 1760 $website = $this->get_request_item( $request );
1270 1761 if ( is_wp_error( $website ) ) {
1271 1762 return $website;
@@ -1270,19 +1761,46 @@
1270 1761 if ( is_wp_error( $website ) ) {
1271 1762 return $website;
1272 1763 }
1273 1764
1765 + $args = $this->prepare_objects_query( $request );
1766 +
1274 1767 $params = array(
1275 1768 'wpid' => $website->id,
1276 1769 'where_extra' => ' AND dismiss = 0 ',
1277 - 'limit' => isset( $request['limit'] ) ? intval( $request['limit'] ) : 200,
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',
1278 1773 );
1279 1774
1280 - $data = MainWP_DB_Site_Actions::instance()->get_wp_actions( $params );
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 + }
1281 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 +
1282 1800 $resp_data = array(
1283 1801 'success' => 1,
1284 - 'total' => is_array( $data ) ? count( $data ) : 0,
1802 + 'total' => $total,
1285 1803 'data' => $data,
1286 1804 'site' => $this->filter_response_data_by_allowed_fields( $website, 'simple_view' ),
1287 1805 );
1288 1806 return rest_ensure_response( $resp_data );
@@ -1294,9 +1812,9 @@
1294 1812 * @param WP_REST_Request $request Full details about the request.
1295 1813 *
1296 1814 * @return WP_Error|WP_REST_Response
1297 1815 */
1298 - public function sync_items( $request ) {
1816 + public function sync_items( $request ) { //phpcs:ignore -- NOSONAR - complex.
1299 1817
1300 1818 $args = $this->prepare_objects_query( $request );
1301 1819 $params = array(
1302 1820 'exclude' => isset( $args['exclude'] ) && ! empty( $args['exclude'] ) ? $args['exclude'] : array(),
@@ -1302,8 +1820,108 @@
1302 1820 'exclude' => isset( $args['exclude'] ) && ! empty( $args['exclude'] ) ? $args['exclude'] : array(),
1303 1821 'include' => isset( $args['include'] ) && ! empty( $args['include'] ) ? $args['include'] : array(),
1304 1822 );
1305 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.
1306 1924 $data = array();
1307 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 ) );
1308 1926 while ( $websites && ( $website = MainWP_DB::fetch_object( $websites ) ) ) {
1309 1927 try {
@@ -1310,9 +1928,9 @@
1310 1928 $ret = MainWP_Sync::sync_site( $website );
1311 1929 } catch ( \Exception $e ) {
1312 1930 $ret = false;
1313 1931 }
1314 - $item = $this->filter_response_data_by_allowed_fields( $this->prepare_item_for_response( $website, $request ), 'view' );
1932 + $item = $this->prepare_site_item_for_response_context( $website, $request, 'view' );
1315 1933 $data[ $website->id ] = array_merge( array( 'result' => $ret ? 'success' : 'failed' ), $item );
1316 1934 }
1317 1935 MainWP_DB::free_result( $websites );
1318 1936 update_option( 'mainwp_last_synced_all_sites', time() );
@@ -1341,9 +1959,10 @@
1341 1959 $ret = MainWP_Manage_Sites_View::m_reconnect_site( $website );
1342 1960 $resp_data['success'] = $ret ? 1 : 0;
1343 1961 } catch ( \Exception $e ) {
1344 1962 // failed.
1345 - return new \WP_Error( 'mainwp_rest_reconnect_site_error', esc_html__( sprintf( 'Reconnect Site "%d" error:', $website->id ) ) . ': ' . $e->getMessage() );
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() );
1346 1965 }
1347 1966 $website = $this->get_site_by( 'id', $website->id );
1348 1967 $resp_data['data'] = $this->filter_response_data_by_allowed_fields( $website, 'simple_view' );
1349 1968 return rest_ensure_response( $resp_data );
@@ -1407,9 +2026,10 @@
1407 2026 $info = MainWP_Connect::fetch_url_authed( $website, 'disconnect' );
1408 2027 $success = is_array( $info ) && isset( $info['result'] ) && 'success' === $info['result'] ? 1 : 0;
1409 2028 $error = is_array( $info ) && ! empty( $info['error'] ) ? $info['error'] : '';
1410 2029 } catch ( \Exception $e ) {
1411 - return new \WP_Error( 'mainwp_rest_disconnect_site_error', esc_html__( sprintf( 'Disconnect Site "%d" error:', $website->id ) ) . ': ' . $e->getMessage() );
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() );
1412 2032 }
1413 2033 $resp_data = array(
1414 2034 'success' => $success,
1415 2035 'data' => $data,
@@ -1568,9 +2188,13 @@
1568 2188
1569 2189 $data = $this->filter_response_data_by_allowed_fields( $website, 'simple_view' );
1570 2190
1571 2191 if ( false !== $new_code ) {
1572 - $data['status'] = MainWP_Monitoring_Handler::get_site_checking_status( $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 + }
1573 2197 $data['http_code'] = $new_code;
1574 2198 }
1575 2199
1576 2200 $resp_data['data'] = $data;
@@ -1598,9 +2222,9 @@
1598 2222 $ret = MainWP_Monitoring_Handler::handle_check_website( $website );
1599 2223 } catch ( \Exception $e ) {
1600 2224 $ret = false;
1601 2225 }
1602 - $item = $this->filter_response_data_by_allowed_fields( $this->prepare_item_for_response( $website, $request ), 'view' );
2226 + $item = $this->prepare_site_item_for_response_context( $website, $request, 'view' );
1603 2227 $data[ $website->id ] = array_merge( array( 'result' => $ret ? 'success' : 'failed' ), $item );
1604 2228 }
1605 2229 MainWP_DB::free_result( $websites );
1606 2230 return rest_ensure_response(
@@ -1765,9 +2389,9 @@
1765 2389 'arg_options' => array(
1766 2390 'sanitize_callback' => 'sanitize_text_field',
1767 2391 ),
1768 2392 ),
1769 - 'tags' => array( // response as tags.
2393 + 'tags' => array(
1770 2394 'type' => 'string',
1771 2395 'description' => __( 'Site tags.', 'mainwp' ),
1772 2396 'context' => array( 'view' ),
1773 2397 ),
@@ -1787,9 +2411,9 @@
1787 2411 'client_id' => array(
1788 2412 'type' => 'integer',
1789 2413 'default' => 0,
1790 2414 'description' => __( 'Client id', 'mainwp' ),
1791 - 'context' => array( 'view', 'simple_view' ),
2415 + 'context' => array( 'view', 'edit', 'simple_view' ),
1792 2416 ),
1793 2417 'is_staging' => array(
1794 2418 'type' => 'integer',
1795 2419 'default' => 0,
@@ -1966,12 +2590,23 @@
1966 2590 'arg_options' => array(
1967 2591 'sanitize_callback' => 'absint',
1968 2592 ),
1969 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.
1970 2605 'http_user' => array(
1971 2606 'type' => 'string',
1972 2607 'description' => __( 'HTTP user', 'mainwp' ),
1973 - 'context' => array( 'view', 'edit' ),
2608 + 'context' => array( 'edit' ),
1974 2609 'arg_options' => array(
1975 2610 'sanitize_callback' => 'sanitize_text_field',
1976 2611 ),
1977 2612 ),
@@ -1977,20 +2612,19 @@
1977 2612 ),
1978 2613 'http_pass' => array(
1979 2614 'type' => 'string',
1980 2615 'description' => __( 'HTTP password', 'mainwp' ),
1981 - 'context' => array( 'view', 'edit' ), // no need to sanitize pass.
2616 + 'context' => array( 'edit' ),
1982 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.
1983 2622 'uniqueId' => array(
1984 2623 'type' => 'string',
1985 2624 'default' => '',
1986 - 'context' => array( 'view' ),
2625 + 'context' => array( 'edit' ),
1987 2626 ),
1988 - 'disable_status_check' => array(
1989 - 'type' => 'string',
1990 - 'default' => '',
1991 - 'context' => array( 'view' ),
1992 - ),
1993 2627 'disable_health_check' => array(
1994 2628 'type' => 'integer',
1995 2629 'default' => 0,
1996 2630 'description' => __( 'Disable health check', 'mainwp' ),
@@ -1999,16 +2633,8 @@
1999 2633 'arg_options' => array(
2000 2634 'sanitize_callback' => 'absint',
2001 2635 ),
2002 2636 ),
2003 - 'status_check_interval' => array(
2004 - 'type' => 'integer',
2005 - 'default' => 0,
2006 - 'context' => array( 'view' ),
2007 - 'arg_options' => array(
2008 - 'sanitize_callback' => 'absint',
2009 - ),
2010 - ),
2011 2637 'health_threshold' => array(
2012 2638 'type' => 'integer',
2013 2639 'default' => 0,
2014 2640 'description' => __( 'Health threshold', 'mainwp' ),
@@ -2039,9 +2665,9 @@
2039 2665 ),
2040 2666 'force_use_ipv4' => array(
2041 2667 'type' => 'string',
2042 2668 'default' => '',
2043 - 'context' => array( 'view' ),
2669 + 'context' => array( 'view', 'edit' ),
2044 2670 ),
2045 2671 'ssl_version' => array(
2046 2672 'type' => 'string',
2047 2673 'default' => '',
@@ -2056,8 +2682,15 @@
2056 2682 'arg_options' => array(
2057 2683 'sanitize_callback' => 'absint',
2058 2684 ),
2059 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 + ),
2060 2693
2061 2694 ),
2062 2695 );
2063 2696 }
@@ -2063,8 +2696,252 @@
2063 2696 }
2064 2697
2065 2698
2066 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 + /**
2067 2944 * Prepare a single order for create or update.
2068 2945 *
2069 2946 * @throws MainWP_Rest_Data_Exception When fails to set any item.
2070 2947 * @param WP_REST_Request $request Request object.
@@ -2070,18 +2947,32 @@
2070 2947 * @param WP_REST_Request $request Request object.
2071 2948 * @return array
2072 2949 */
2073 2950 protected function prepare_object_for_database( $request ) {
2074 - $item_fields = array();
2075 - $item_fields['url'] = isset( $request['url'] ) ? sanitize_text_field( wp_unslash( $request['url'] ) ) : '';
2076 - $item_fields['name'] = isset( $request['name'] ) ? sanitize_text_field( wp_unslash( $request['name'] ) ) : '';
2077 - $item_fields['wpadmin'] = isset( $request['admin'] ) ? sanitize_text_field( wp_unslash( $request['admin'] ) ) : '';
2078 - $item_fields['unique_id'] = isset( $request['uniqueid'] ) ? sanitize_text_field( wp_unslash( $request['uniqueid'] ) ) : '';
2079 - $item_fields['ssl_verify'] = empty( $request['ssl_verify'] ) ? false : intval( $request['ssl_verify'] );
2080 - $item_fields['force_use_ipv4'] = isset( $request['force_use_ipv4'] ) && mainwp_string_to_bool( $request['force_use_ipv4'] ) ? 1 : 0;
2081 - $item_fields['http_user'] = isset( $request['http_user'] ) ? sanitize_text_field( wp_unslash( $request['http_user'] ) ) : '';
2082 - $item_fields['http_pass'] = isset( $request['http_pass'] ) ? wp_unslash( $request['http_pass'] ) : '';
2083 - $item_fields['groupids'] = isset( $request['groupids'] ) && ! empty( $request['groupids'] ) ? explode( ',', sanitize_text_field( wp_unslash( $request['groupids'] ) ) ) : array();
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;
2084 2975
2085 2976 /**
2086 2977 * Filters an object before it is inserted via the REST API.
2087 2978 *
@@ -2108,10 +2999,8 @@
2108 2999 'adminname' => 'admin',
2109 3000 'ssl_version' => 'sslversion',
2110 3001 'uniqueId' => 'uniqueid',
2111 3002 'protocol' => 'protocol',
2112 - 'disable_status_check' => 'disablechecking',
2113 - 'status_check_interval' => 'checkinterval',
2114 3003 'disable_health_check' => 'disablehealthchecking',
2115 3004 'health_threshold' => 'healththreshold',
2116 3005 'suspended' => 'suspended',
2117 3006 'groupids' => 'groupids',
@@ -2124,14 +3013,26 @@
2124 3013 'monitoring_emails' => 'monitoring_emails',
2125 3014 );
2126 3015
2127 3016 $data = array();
2128 - foreach ( $map_fields_update as $field ) {
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 ) {
2129 3021 if ( isset( $request[ $field ] ) ) {
2130 3022 $data[ $field ] = $request[ $field ];
3023 + } elseif ( isset( $request[ $schema_field ] ) ) {
3024 + $data[ $field ] = $request[ $schema_field ];
2131 3025 }
2132 3026 }
2133 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 +
2134 3035 /**
2135 3036 * Filters an object before it is inserted via the REST API.
2136 3037 *
2137 3038 * @since 5.2
@@ -2180,40 +3081,38 @@
2180 3081 }
2181 3082
2182 3083 if ( ! empty( $args['full_data'] ) ) {
2183 3084 $formatted_extra = array(
2184 - 'id' => $site_object->id,
2185 - 'url' => $site_object->url,
2186 - 'name' => $site_object->name,
2187 - 'offline_checks_last' => $site_object->offline_checks_last,
2188 - 'offline_check_result' => $site_object->offline_check_result,
2189 - 'http_response_code' => $site_object->http_response_code,
2190 - 'disable_status_check' => $site_object->disable_status_check,
2191 - 'disable_health_check' => $site_object->disable_health_check,
2192 - 'status_check_interval' => $site_object->status_check_interval,
2193 - 'health_threshold' => $site_object->health_threshold,
2194 - 'note' => $site_object->note,
2195 - 'plugin_upgrades' => $site_object->plugin_upgrades,
2196 - 'theme_upgrades' => $site_object->theme_upgrades,
2197 - 'translation_upgrades' => $site_object->translation_upgrades,
2198 - 'securityIssues' => $site_object->securityIssues,
2199 - 'themes' => $site_object->themes,
2200 - 'plugins' => $site_object->plugins,
2201 - 'automatic_update' => $site_object->automatic_update,
2202 - 'sync_errors' => $site_object->sync_errors,
2203 - 'last_post_gmt' => $site_object->last_post_gmt,
2204 - 'health_value' => $site_object->health_value,
2205 - 'phpversion' => $site_object->phpversion,
2206 - 'wp_upgrades' => $site_object->wp_upgrades,
2207 - 'security_stats' => $site_object->security_stats,
2208 - 'client_id' => $site_object->client_id,
2209 - 'adminname' => $site_object->adminname,
2210 - 'http_user' => $site_object->http_user,
2211 - 'http_pass' => $site_object->http_pass,
2212 - 'ssl_version' => $site_object->ssl_version,
2213 - 'signature_algo' => $site_object->signature_algo,
2214 - 'verify_method' => $site_object->verify_method,
2215 - 'verify_certificate' => $site_object->verify_certificate,
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,
2216 3115 );
2217 3116
2218 3117 $format_date = array( 'dtsAutomaticSync', 'dtsAutomaticSyncStart', 'dtsSync', 'dtsSyncStart' );
2219 3118 // Format date values.
@@ -2239,8 +3138,12 @@
2239 3138 * @return WP_REST_Response $response Response data.
2240 3139 */
2241 3140 public function prepare_item_for_response( $item, $request, $args = array() ) { //phpcs:ignore -- NOSONAR - complex.
2242 3141
3142 + if ( ! is_object( $item ) ) {
3143 + $item = new \stdClass();
3144 + }
3145 +
2243 3146 $fields = $this->get_fields_for_response( $request );
2244 3147
2245 3148 if ( ! is_array( $fields ) ) {
2246 3149 $fields = array();
@@ -2275,9 +3178,8 @@
2275 3178
2276 3179 $map_fields = array(
2277 3180 'database_size' => 'dbsize',
2278 3181 'http_status' => 'http_response_code',
2279 - 'health_score' => 'health_value',
2280 3182 'icon' => 'cust_site_icon_info',
2281 3183 'last_sync' => 'dtsSync',
2282 3184 'last_post_time' => 'last_post_gmt',
2283 3185 );
@@ -2287,8 +3189,20 @@
2287 3189 if ( in_array( $field1, $fields ) && property_exists( $item, $field2 ) ) {
2288 3190 $data[ $field1 ] = $item->{$field2};
2289 3191 }
2290 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 + }
2291 3205 }
2292 3206
2293 3207 $website_info = MainWP_DB::instance()->get_website_option( $item->id, 'site_info' );
2294 3208 $website_info = ! empty( $website_info ) ? json_decode( $website_info, true ) : array();
@@ -2353,8 +3267,18 @@
2353 3267 if ( isset( $data['last_sync'] ) ) {
2354 3268 $data['last_sync'] = mainwp_rest_prepare_date_response( $data['last_sync'] );
2355 3269 }
2356 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 +
2357 3281 /**
2358 3282 * Filterobject returned from the REST API.
2359 3283 *
2360 3284 * @param array $data The object.
@@ -2373,16 +3297,36 @@
2373 3297 * @param WP_REST_Request $request Full details about the request.
2374 3298 *
2375 3299 * @return WP_Error|WP_REST_Response
2376 3300 */
2377 - public function create_item( $request ) {
3301 + public function create_item( $request ) { //phpcs:ignore -- NOSONAR - complex.
2378 3302 $resp_data = array();
2379 3303 $resp_data['success'] = 0;
3304 + $site_id = 0;
3305 + $data = array(); //phpcs:ignore -- NOSONAR - not used data.
3306 + $found_id = 0;
2380 3307 try {
2381 - $item = $this->prepare_object_for_database( $request );
2382 - $website = MainWP_DB::instance()->get_websites_by_url( $item['url'] );
2383 - list( $message, $error, $site_id ) = MainWP_Manage_Sites_View::add_wp_site( $website, $item );
2384 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 +
2385 3329 if ( ! empty( $site_id ) ) {
2386 3330 $site = MainWP_DB::instance()->get_website_by_id( $site_id );
2387 3331 $resp_data['success'] = 1;
2388 3332 $resp_data['data'] = $this->filter_response_data_by_allowed_fields( $site, 'simple_view' );
@@ -2397,8 +3341,13 @@
2397 3341 }
2398 3342 } catch ( Exception $e ) {
2399 3343 $resp_data['error'] = wp_strip_all_tags( $e->getMessage() );
2400 3344 }
3345 +
3346 + if ( ! empty( $found_id ) ) {
3347 + $resp_data['found_id'] = $found_id;
3348 + }
3349 +
2401 3350 return rest_ensure_response( $resp_data );
2402 3351 }
2403 3352
2404 3353 /**
@@ -2412,13 +3361,41 @@
2412 3361 $website = $this->get_request_item( $request );
2413 3362 if ( is_wp_error( $website ) ) {
2414 3363 return $website;
2415 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 +
2416 3386 $resp_data = array();
2417 3387 $resp_data['success'] = 0;
2418 3388 try {
2419 3389 $item = $this->prepare_object_for_update( $request );
2420 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 + }
2421 3398 if ( is_array( $result ) && ! empty( $result['success'] ) ) {
2422 3399 $resp_data['success'] = 1;
2423 3400 $params = array(
2424 3401 'full_data' => true,
@@ -2427,10 +3404,10 @@
2427 3404 'fields' => $this->get_fields_for_response( $request ),
2428 3405 );
2429 3406 $websites = MainWP_DB::instance()->get_websites_for_current_user( $params );
2430 3407 $data = $websites ? current( $websites ) : array();
2431 - $resp_data['data'] = $this->filter_response_data_by_allowed_fields( $this->prepare_item_for_response( $data, $request ), 'view' );
2432 - } else {
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'] ) ) {
2433 3410 $resp_data['error'] = esc_html__( 'Update site failed. Please try again.', 'mainwp' );
2434 3411 }
2435 3412 } catch ( \Exception $e ) {
2436 3413 $resp_data['error'] = $e->getMessage();