PluginProbe
MainWP Dashboard: Self-hosted WordPress Management for Agencies / 6.2.1
MainWP Dashboard: Self-hosted WordPress Management for Agencies v6.2.1
6.2.2 6.2.1 6.2 6.1.8 6.1.7 6.1.6 6.1.5 6.1.4 6.1.3 6.1.2 6.1.1 6.1 6.0.12 6.0.11 4.6.0.1 5.0 5.0.1 5.0.2 5.0.3 5.0.3.1 5.0.3.2 5.1 5.1.1 5.2 5.2.1 All 155 releases
← All changes | includes/rest-api/controller/version2/class-mainwp-rest-sites-controller.php +493 -71 6.0.12 → 6.2.1 View file →
@@ -108,8 +108,52 @@
108 108 return $this->get_allowed_fields_by_context( $context );
109 109 }
110 110
111 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 + /**
112 156 * Prepare a site response item while preserving `_fields` projections.
113 157 *
114 158 * `prepare_item_for_response()` already narrows the payload when `_fields`
115 159 * is present. Running the result back through
@@ -115,11 +159,12 @@
115 159 * is present. Running the result back through
116 160 * `filter_response_data_by_allowed_fields()` expands it again to all schema
117 161 * fields with empty-string placeholders. This helper avoids that behavior.
118 162 *
119 - * @param object $item Site item.
120 - * @param WP_REST_Request $request Request object.
121 - * @param string $context Response context.
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.
122 167 *
123 168 * @return array
124 169 */
125 170 protected function prepare_site_item_for_response_context( $item, $request, $context = 'view', $addition_fields = array() ) {
@@ -124,13 +169,29 @@
124 169 */
125 170 protected function prepare_site_item_for_response_context( $item, $request, $context = 'view', $addition_fields = array() ) {
126 171 $prepared = $this->prepare_item_for_response( $item, $request );
127 172
128 - if ( $request instanceof WP_REST_Request && ! empty( $request['_fields'] ) ) {
129 - return $prepared;
173 + if ( ! ( $request instanceof WP_REST_Request && ! empty( $request['_fields'] ) ) ) {
174 + $prepared = $this->filter_response_data_by_allowed_fields( $prepared, $context, $addition_fields );
130 175 }
131 176
132 - return $this->filter_response_data_by_allowed_fields( $prepared, $context, $addition_fields );
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 );
133 194 }
134 195
135 196 /**
136 197 * Preserve MainWP's custom response envelopes when `_fields` is used.
@@ -308,9 +369,9 @@
308 369 array(
309 370 'methods' => WP_REST_Server::CREATABLE,
310 371 'callback' => array( $this, 'create_item' ),
311 372 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
312 - 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
373 + 'args' => $this->get_add_site_args(),
313 374 ),
314 375 )
315 376 );
316 377
@@ -321,8 +382,9 @@
321 382 array(
322 383 'methods' => WP_REST_Server::EDITABLE,
323 384 'callback' => array( $this, 'update_item' ),
324 385 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
386 + 'args' => $this->get_edit_site_args(),
325 387 ),
326 388 )
327 389 );
328 390
@@ -596,8 +658,42 @@
596 658 }
597 659
598 660
599 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 + /**
600 696 * Get item.
601 697 *
602 698 * @param WP_REST_Request $request Request object.
603 699 *
@@ -616,9 +712,9 @@
616 712
617 713 // Extract with_tags flag early so both paths use the same value.
618 714 // Default to true for consistency with get_items() endpoint.
619 715 $with_tags = isset( $request['with_tags'] ) ? mainwp_string_to_bool( $request['with_tags'] ) : true;
620 - $custom_fields = isset( $request['custom_fields'] ) ? array_map( 'trim', wp_parse_list( $request['custom_fields'] ) ) : array();
716 + $custom_fields = $this->get_requested_custom_fields( $request );
621 717
622 718 // Try abilities-first approach (with fallback to legacy logic).
623 719 $ability = function_exists( 'wp_get_ability' ) ? wp_get_ability( 'mainwp/get-site-v1' ) : null;
624 720
@@ -643,9 +739,8 @@
643 739 // prepare_item_for_response() + filter_response_data_by_allowed_fields().
644 740 // We re-fetch from DB to apply REST-specific options (with_tags, fields) that
645 741 // the ability schema does not support. This ensures consistent response format
646 742 // between ability and legacy paths.
647 - // See: .mwpdev/plans/abilities-api/phase-3-rest-integration-notes.md.
648 743
649 744 $site_data = ! empty( $result ) && is_array( $result ) ? $result : array();
650 745 $site_id = isset( $site_data['id'] ) ? (int) $site_data['id'] : 0;
651 746
@@ -653,10 +748,11 @@
653 748 $params = array(
654 749 'full_data' => true,
655 750 'selectgroups' => $with_tags,
656 751 'include' => array( $site_id ),
657 - 'extra_view' => $custom_fields, // Pass custom fields to prepare_item_for_response() via params for abilities path since ability schema does not support fields parameter.
658 - 'fields' => $this->get_fields_for_response( $request ),
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 ) ),
659 755 );
660 756
661 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.
662 758 if ( ! empty( $custom_fields ) ) {
@@ -687,10 +783,11 @@
687 783 $params = array(
688 784 'full_data' => true,
689 785 'selectgroups' => $with_tags,
690 786 'include' => array( $item->id ),
691 - 'extra_view' => $custom_fields, // Pass custom fields to prepare_item_for_response() via params for abilities path since ability schema does not support fields parameter.
692 - '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 ) ),
693 790 );
694 791
695 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.
696 793 if ( ! empty( $custom_fields ) ) {
@@ -724,9 +821,9 @@
724 821 // Check REST-specific flags that affect response format.
725 822 // These are processed in the legacy path but not in the ability schema.
726 823 $with_tags = isset( $request['with_tags'] ) ? mainwp_string_to_bool( $request['with_tags'] ) : true;
727 824 $full_data = isset( $request['full_data'] ) ? mainwp_string_to_bool( $request['full_data'] ) : true;
728 - $custom_fields = isset( $request['custom_fields'] ) ? array_map( 'trim', wp_parse_list( $request['custom_fields'] ) ) : array();
825 + $custom_fields = $this->get_requested_custom_fields( $request );
729 826
730 827 // Try abilities-first approach (with fallback to legacy logic).
731 828 // Skip abilities when with_tags=false, full_data=false, or include/exclude
732 829 // are set (ability returns paginated results that would corrupt metadata).
@@ -766,9 +863,8 @@
766 863 // prepare_item_for_response() + filter_response_data_by_allowed_fields().
767 864 // We re-fetch from DB using the IDs returned by the ability to apply REST-specific
768 865 // options (with_tags, fields) that the ability schema does not support.
769 866 // This ensures consistent response format between ability and legacy paths.
770 - // See: .mwpdev/plans/abilities-api/phase-3-rest-integration-notes.md
771 867 $normalized_items = array();
772 868 if ( ! empty( $result['items'] ) ) {
773 869 // Get site IDs from ability results.
774 870 $site_ids = array_map( fn( $item ) => isset( $item['id'] ) ? (int) $item['id'] : 0, $result['items'] );
@@ -779,10 +875,11 @@
779 875 $params = array(
780 876 'full_data' => true,
781 877 'selectgroups' => true,
782 878 'include' => $site_ids,
783 - 'extra_view' => $custom_fields, // Pass custom fields to prepare_item_for_response() via params for abilities path since ability schema does not support fields parameter.
784 - 'fields' => $this->get_fields_for_response( $request ),
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 ) ),
785 882 );
786 883
787 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.
788 885 if ( ! empty( $custom_fields ) ) {
@@ -810,8 +907,14 @@
810 907
811 908 // Fallback: Legacy logic when Abilities API is not available.
812 909 $args['selectgroups'] = isset( $request['with_tags'] ) ? mainwp_string_to_bool( $request['with_tags'] ) : true;
813 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 );
814 917
815 918 // get data.
816 919 $websites = MainWP_DB::instance()->get_websites_for_current_user( $args );
817 920
@@ -847,8 +950,15 @@
847 950 }
848 951
849 952 $args['selectgroups'] = isset( $request['with_tags'] ) ? mainwp_string_to_bool( $request['with_tags'] ) : false;
850 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 );
851 961
852 962 // get data.
853 963 $websites = MainWP_DB::instance()->get_websites_for_current_user( $args );
854 964
@@ -1656,11 +1766,11 @@
1656 1766
1657 1767 $params = array(
1658 1768 'wpid' => $website->id,
1659 1769 'where_extra' => ' AND dismiss = 0 ',
1660 - 'order' => ! empty( $args['order'] ) ? sanitize_text_field( wp_unslash( $args['order'] ) ) : '',
1661 - 'order_by' => ! empty( $args['orderby'] ) ? sanitize_text_field( wp_unslash( $args['orderby'] ) ) : '',
1662 - 'source' => ! empty( $request['source'] ) ? sanitize_text_field( wp_unslash( $request['source'] ) ) : 'wpadmin',
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',
1663 1773 );
1664 1774
1665 1775 if ( ! empty( $args['paged'] ) && ! empty( $args['items_per_page'] ) ) {
1666 1776 $params['rowcount'] = intval( $args['items_per_page'] );
@@ -1671,10 +1781,10 @@
1671 1781 $params['rowcount'] = $limit;
1672 1782 $params['offset'] = 0;
1673 1783 }
1674 1784
1675 - $params['actions'] = ! empty( $request['actions'] ) ? sanitize_text_field( wp_unslash( $request['actions'] ) ) : '';
1676 - $params['contexts'] = ! empty( $request['contexts'] ) ? sanitize_text_field( wp_unslash( $request['contexts'] ) ) : '';
1785 + $params['actions'] = ! empty( $request['actions'] ) ? sanitize_text_field( $request['actions'] ) : '';
1786 + $params['contexts'] = ! empty( $request['contexts'] ) ? sanitize_text_field( $request['contexts'] ) : '';
1677 1787 $params['total_count'] = ! empty( $request['total_count'] ) ? mainwp_string_to_bool( $request['total_count'] ) : false;
1678 1788 $params['optimize_view'] = ! empty( $request['optimize_view'] ) ? mainwp_string_to_bool( $request['optimize_view'] ) : false;
1679 1789 $params['optimize_with_meta'] = ! empty( $request['optimize_with_meta'] ) ? mainwp_string_to_bool( $request['optimize_with_meta'] ) : false;
1680 1790
@@ -2279,9 +2389,9 @@
2279 2389 'arg_options' => array(
2280 2390 'sanitize_callback' => 'sanitize_text_field',
2281 2391 ),
2282 2392 ),
2283 - 'tags' => array( // response as tags.
2393 + 'tags' => array(
2284 2394 'type' => 'string',
2285 2395 'description' => __( 'Site tags.', 'mainwp' ),
2286 2396 'context' => array( 'view' ),
2287 2397 ),
@@ -2480,12 +2590,23 @@
2480 2590 'arg_options' => array(
2481 2591 'sanitize_callback' => 'absint',
2482 2592 ),
2483 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.
2484 2605 'http_user' => array(
2485 2606 'type' => 'string',
2486 2607 'description' => __( 'HTTP user', 'mainwp' ),
2487 - 'context' => array( 'view', 'edit' ),
2608 + 'context' => array( 'edit' ),
2488 2609 'arg_options' => array(
2489 2610 'sanitize_callback' => 'sanitize_text_field',
2490 2611 ),
2491 2612 ),
@@ -2491,14 +2612,18 @@
2491 2612 ),
2492 2613 'http_pass' => array(
2493 2614 'type' => 'string',
2494 2615 'description' => __( 'HTTP password', 'mainwp' ),
2495 - 'context' => array( 'view', 'edit' ), // no need to sanitize pass.
2616 + 'context' => array( 'edit' ),
2496 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.
2497 2622 'uniqueId' => array(
2498 2623 'type' => 'string',
2499 2624 'default' => '',
2500 - 'context' => array( 'view' ),
2625 + 'context' => array( 'edit' ),
2501 2626 ),
2502 2627 'disable_health_check' => array(
2503 2628 'type' => 'integer',
2504 2629 'default' => 0,
@@ -2540,9 +2665,9 @@
2540 2665 ),
2541 2666 'force_use_ipv4' => array(
2542 2667 'type' => 'string',
2543 2668 'default' => '',
2544 - 'context' => array( 'view' ),
2669 + 'context' => array( 'view', 'edit' ),
2545 2670 ),
2546 2671 'ssl_version' => array(
2547 2672 'type' => 'string',
2548 2673 'default' => '',
@@ -2557,8 +2682,15 @@
2557 2682 'arg_options' => array(
2558 2683 'sanitize_callback' => 'absint',
2559 2684 ),
2560 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 + ),
2561 2693
2562 2694 ),
2563 2695 );
2564 2696 }
@@ -2564,8 +2696,252 @@
2564 2696 }
2565 2697
2566 2698
2567 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 + /**
2568 2944 * Prepare a single order for create or update.
2569 2945 *
2570 2946 * @throws MainWP_Rest_Data_Exception When fails to set any item.
2571 2947 * @param WP_REST_Request $request Request object.
@@ -2571,20 +2947,32 @@
2571 2947 * @param WP_REST_Request $request Request object.
2572 2948 * @return array
2573 2949 */
2574 2950 protected function prepare_object_for_database( $request ) {
2575 - $item_fields = array();
2576 - $item_fields['url'] = isset( $request['url'] ) ? sanitize_text_field( wp_unslash( $request['url'] ) ) : '';
2577 - $item_fields['name'] = isset( $request['name'] ) ? sanitize_text_field( wp_unslash( $request['name'] ) ) : '';
2578 - $item_fields['wpadmin'] = isset( $request['admin'] ) ? sanitize_text_field( wp_unslash( $request['admin'] ) ) : '';
2579 - $item_fields['adminpwd'] = isset( $request['adminpassword'] ) ? wp_unslash( $request['adminpassword'] ) : '';
2580 - $item_fields['unique_id'] = isset( $request['uniqueid'] ) ? sanitize_text_field( wp_unslash( $request['uniqueid'] ) ) : '';
2581 - $item_fields['ssl_verify'] = empty( $request['ssl_verify'] ) ? false : intval( $request['ssl_verify'] );
2582 - $item_fields['force_use_ipv4'] = isset( $request['force_use_ipv4'] ) && mainwp_string_to_bool( $request['force_use_ipv4'] ) ? 1 : 0;
2583 - $item_fields['http_user'] = isset( $request['http_user'] ) ? sanitize_text_field( wp_unslash( $request['http_user'] ) ) : '';
2584 - $item_fields['http_pass'] = isset( $request['http_pass'] ) ? wp_unslash( $request['http_pass'] ) : '';
2585 - $item_fields['groupids'] = isset( $request['groupids'] ) && ! empty( $request['groupids'] ) ? explode( ',', sanitize_text_field( wp_unslash( $request['groupids'] ) ) ) : array();
2586 - $item_fields['clientid'] = isset( $request['client_id'] ) && ! empty( $request['client_id'] ) ? intval( $request['client_id'] ) : 0;
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;
2587 2975
2588 2976 /**
2589 2977 * Filters an object before it is inserted via the REST API.
2590 2978 *
@@ -2625,15 +3013,25 @@
2625 3013 'monitoring_emails' => 'monitoring_emails',
2626 3014 );
2627 3015
2628 3016 $data = array();
2629 - 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 ) {
2630 3021 if ( isset( $request[ $field ] ) ) {
2631 3022 $data[ $field ] = $request[ $field ];
3023 + } elseif ( isset( $request[ $schema_field ] ) ) {
3024 + $data[ $field ] = $request[ $schema_field ];
2632 3025 }
2633 3026 }
2634 3027
2635 - $data['client_id'] = isset( $request['client_id'] ) && ! empty( $request['client_id'] ) ? intval( $request['client_id'] ) : 0;
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 + }
2636 3034
2637 3035 /**
2638 3036 * Filters an object before it is inserted via the REST API.
2639 3037 *
@@ -2780,9 +3178,8 @@
2780 3178
2781 3179 $map_fields = array(
2782 3180 'database_size' => 'dbsize',
2783 3181 'http_status' => 'http_response_code',
2784 - 'health_score' => 'health_value',
2785 3182 'icon' => 'cust_site_icon_info',
2786 3183 'last_sync' => 'dtsSync',
2787 3184 'last_post_time' => 'last_post_gmt',
2788 3185 );
@@ -2792,8 +3189,20 @@
2792 3189 if ( in_array( $field1, $fields ) && property_exists( $item, $field2 ) ) {
2793 3190 $data[ $field1 ] = $item->{$field2};
2794 3191 }
2795 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 + }
2796 3205 }
2797 3206
2798 3207 $website_info = MainWP_DB::instance()->get_website_option( $item->id, 'site_info' );
2799 3208 $website_info = ! empty( $website_info ) ? json_decode( $website_info, true ) : array();
@@ -2858,9 +3267,9 @@
2858 3267 if ( isset( $data['last_sync'] ) ) {
2859 3268 $data['last_sync'] = mainwp_rest_prepare_date_response( $data['last_sync'] );
2860 3269 }
2861 3270
2862 - $custom_fields = isset( $request['custom_fields'] ) ? array_map( 'trim', wp_parse_list( $request['custom_fields'] ) ) : array();
3271 + $custom_fields = $this->get_requested_custom_fields( $request );
2863 3272
2864 3273 if ( is_array( $custom_fields ) && ! empty( $custom_fields ) ) {
2865 3274 foreach ( $custom_fields as $field ) {
2866 3275 if ( property_exists( $item, $field ) && ! isset( $data[ $field ] ) ) {
@@ -2892,12 +3301,22 @@
2892 3301 public function create_item( $request ) { //phpcs:ignore -- NOSONAR - complex.
2893 3302 $resp_data = array();
2894 3303 $resp_data['success'] = 0;
2895 3304 $site_id = 0;
2896 - $url = '';
2897 3305 $data = array(); //phpcs:ignore -- NOSONAR - not used data.
2898 3306 $found_id = 0;
2899 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 +
2900 3319 $item = $this->prepare_object_for_database( $request );
2901 3320
2902 3321 if ( empty( $item['name'] ) && ! empty( $item['url'] ) ) {
2903 3322 $item['name'] = MainWP_Utility::remove_http_prefix( $item['url'], true );
@@ -2903,10 +3322,9 @@
2903 3322 $item['name'] = MainWP_Utility::remove_http_prefix( $item['url'], true );
2904 3323 $item['name'] = rtrim( $item['name'], '/' );
2905 3324 }
2906 3325
2907 - $url = $item['url'];
2908 - $website = MainWP_DB::instance()->get_websites_by_url( $item['url'] );
3326 + $website = MainWP_DB::instance()->get_websites_by_url( $item['url'] );
2909 3327 list( $message, $error, $site_id, $found_id ) = MainWP_Manage_Sites_View::add_wp_site( $website, $item );
2910 3328
2911 3329 if ( ! empty( $site_id ) ) {
2912 3330 $site = MainWP_DB::instance()->get_website_by_id( $site_id );
@@ -2924,34 +3342,10 @@
2924 3342 } catch ( Exception $e ) {
2925 3343 $resp_data['error'] = wp_strip_all_tags( $e->getMessage() );
2926 3344 }
2927 3345
2928 - $user = $this->get_rest_api_user();
2929 - $is_dashboard_connect = ! empty( $user ) && 1 === (int) $user->key_type ? true : false;
2930 -
2931 - if ( $is_dashboard_connect ) {
2932 - if ( ! empty( $resp_data['error'] ) ) {
2933 - MainWP_Logger::instance()->log_action( 'Dashboard Connect add Site :: [url=' . $url . '] :: [result=Failed] :: [error=' . esc_html( $resp_data['error'] ) . ']', MainWP_Logger::CONNECT_LOG_PRIORITY );
2934 - } elseif ( ! empty( $resp_data['success'] ) ) {
2935 - MainWP_Logger::instance()->log_action( 'Dashboard Connect add Site :: [url=' . $url . '] :: [result=Success]', MainWP_Logger::CONNECT_LOG_PRIORITY );
2936 - }
2937 - }
2938 -
2939 3346 if ( ! empty( $found_id ) ) {
2940 3347 $resp_data['found_id'] = $found_id;
2941 - // if found connected and is dashboard connect request then try to reconnect to prevent incorrect connection.
2942 - if ( $is_dashboard_connect ) {
2943 - $reconnect = false;
2944 - $site = MainWP_DB::instance()->get_website_by_id( $found_id );
2945 - try {
2946 - $reconnect = MainWP_Manage_Sites_View::m_reconnect_site( $site );
2947 - $resp_data['reconnect_success'] = $reconnect ? 1 : 0;
2948 - } catch ( \Exception $e ) {
2949 - // failed.
2950 - $resp_data['reconnect_error'] = $e->getMessage();
2951 - }
2952 - MainWP_Logger::instance()->log_action( 'Dashboard Connect reconnect site :: [url=' . $url . '] :: [result=' . ( $reconnect ? 'success' : 'failed' ) . ']', MainWP_Logger::CONNECT_LOG_PRIORITY );
2953 - }
2954 3348 }
2955 3349
2956 3350 return rest_ensure_response( $resp_data );
2957 3351 }
@@ -2967,13 +3361,41 @@
2967 3361 $website = $this->get_request_item( $request );
2968 3362 if ( is_wp_error( $website ) ) {
2969 3363 return $website;
2970 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 +
2971 3386 $resp_data = array();
2972 3387 $resp_data['success'] = 0;
2973 3388 try {
2974 3389 $item = $this->prepare_object_for_update( $request );
2975 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 + }
2976 3398 if ( is_array( $result ) && ! empty( $result['success'] ) ) {
2977 3399 $resp_data['success'] = 1;
2978 3400 $params = array(
2979 3401 'full_data' => true,
@@ -2983,9 +3405,9 @@
2983 3405 );
2984 3406 $websites = MainWP_DB::instance()->get_websites_for_current_user( $params );
2985 3407 $data = $websites ? current( $websites ) : array();
2986 3408 $resp_data['data'] = $this->prepare_site_item_for_response_context( $data, $request, 'view' );
2987 - } else {
3409 + } elseif ( empty( $resp_data['group_message'] ) && empty( $resp_data['message'] ) ) {
2988 3410 $resp_data['error'] = esc_html__( 'Update site failed. Please try again.', 'mainwp' );
2989 3411 }
2990 3412 } catch ( \Exception $e ) {
2991 3413 $resp_data['error'] = $e->getMessage();