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 +417 -43 6.1.1 → 6.2.1 View file →
@@ -126,8 +126,11 @@
126 126 'http_pass',
127 127 'adminname',
128 128 'securekey',
129 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',
130 133 );
131 134
132 135 /**
133 136 * Strip credential-shaped fields from a prepared response item in place.
@@ -156,11 +159,12 @@
156 159 * is present. Running the result back through
157 160 * `filter_response_data_by_allowed_fields()` expands it again to all schema
158 161 * fields with empty-string placeholders. This helper avoids that behavior.
159 162 *
160 - * @param object $item Site item.
161 - * @param WP_REST_Request $request Request object.
162 - * @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.
163 167 *
164 168 * @return array
165 169 */
166 170 protected function prepare_site_item_for_response_context( $item, $request, $context = 'view', $addition_fields = array() ) {
@@ -365,9 +369,9 @@
365 369 array(
366 370 'methods' => WP_REST_Server::CREATABLE,
367 371 'callback' => array( $this, 'create_item' ),
368 372 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
369 - 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
373 + 'args' => $this->get_add_site_args(),
370 374 ),
371 375 )
372 376 );
373 377
@@ -378,8 +382,9 @@
378 382 array(
379 383 'methods' => WP_REST_Server::EDITABLE,
380 384 'callback' => array( $this, 'update_item' ),
381 385 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
386 + 'args' => $this->get_edit_site_args(),
382 387 ),
383 388 )
384 389 );
385 390
@@ -653,8 +658,42 @@
653 658 }
654 659
655 660
656 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 + /**
657 696 * Get item.
658 697 *
659 698 * @param WP_REST_Request $request Request object.
660 699 *
@@ -673,9 +712,9 @@
673 712
674 713 // Extract with_tags flag early so both paths use the same value.
675 714 // Default to true for consistency with get_items() endpoint.
676 715 $with_tags = isset( $request['with_tags'] ) ? mainwp_string_to_bool( $request['with_tags'] ) : true;
677 - $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 );
678 717
679 718 // Try abilities-first approach (with fallback to legacy logic).
680 719 $ability = function_exists( 'wp_get_ability' ) ? wp_get_ability( 'mainwp/get-site-v1' ) : null;
681 720
@@ -700,9 +739,8 @@
700 739 // prepare_item_for_response() + filter_response_data_by_allowed_fields().
701 740 // We re-fetch from DB to apply REST-specific options (with_tags, fields) that
702 741 // the ability schema does not support. This ensures consistent response format
703 742 // between ability and legacy paths.
704 - // See: .mwpdev/plans/abilities-api/phase-3-rest-integration-notes.md.
705 743
706 744 $site_data = ! empty( $result ) && is_array( $result ) ? $result : array();
707 745 $site_id = isset( $site_data['id'] ) ? (int) $site_data['id'] : 0;
708 746
@@ -710,10 +748,11 @@
710 748 $params = array(
711 749 'full_data' => true,
712 750 'selectgroups' => $with_tags,
713 751 'include' => array( $site_id ),
714 - '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.
715 - '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 ) ),
716 755 );
717 756
718 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.
719 758 if ( ! empty( $custom_fields ) ) {
@@ -744,10 +783,11 @@
744 783 $params = array(
745 784 'full_data' => true,
746 785 'selectgroups' => $with_tags,
747 786 'include' => array( $item->id ),
748 - '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.
749 - '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 ) ),
750 790 );
751 791
752 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.
753 793 if ( ! empty( $custom_fields ) ) {
@@ -781,9 +821,9 @@
781 821 // Check REST-specific flags that affect response format.
782 822 // These are processed in the legacy path but not in the ability schema.
783 823 $with_tags = isset( $request['with_tags'] ) ? mainwp_string_to_bool( $request['with_tags'] ) : true;
784 824 $full_data = isset( $request['full_data'] ) ? mainwp_string_to_bool( $request['full_data'] ) : true;
785 - $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 );
786 826
787 827 // Try abilities-first approach (with fallback to legacy logic).
788 828 // Skip abilities when with_tags=false, full_data=false, or include/exclude
789 829 // are set (ability returns paginated results that would corrupt metadata).
@@ -823,9 +863,8 @@
823 863 // prepare_item_for_response() + filter_response_data_by_allowed_fields().
824 864 // We re-fetch from DB using the IDs returned by the ability to apply REST-specific
825 865 // options (with_tags, fields) that the ability schema does not support.
826 866 // This ensures consistent response format between ability and legacy paths.
827 - // See: .mwpdev/plans/abilities-api/phase-3-rest-integration-notes.md
828 867 $normalized_items = array();
829 868 if ( ! empty( $result['items'] ) ) {
830 869 // Get site IDs from ability results.
831 870 $site_ids = array_map( fn( $item ) => isset( $item['id'] ) ? (int) $item['id'] : 0, $result['items'] );
@@ -836,10 +875,11 @@
836 875 $params = array(
837 876 'full_data' => true,
838 877 'selectgroups' => true,
839 878 'include' => $site_ids,
840 - '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.
841 - '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 ) ),
842 882 );
843 883
844 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.
845 885 if ( ! empty( $custom_fields ) ) {
@@ -867,8 +907,14 @@
867 907
868 908 // Fallback: Legacy logic when Abilities API is not available.
869 909 $args['selectgroups'] = isset( $request['with_tags'] ) ? mainwp_string_to_bool( $request['with_tags'] ) : true;
870 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 );
871 917
872 918 // get data.
873 919 $websites = MainWP_DB::instance()->get_websites_for_current_user( $args );
874 920
@@ -904,8 +950,15 @@
904 950 }
905 951
906 952 $args['selectgroups'] = isset( $request['with_tags'] ) ? mainwp_string_to_bool( $request['with_tags'] ) : false;
907 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 );
908 961
909 962 // get data.
910 963 $websites = MainWP_DB::instance()->get_websites_for_current_user( $args );
911 964
@@ -1713,11 +1766,11 @@
1713 1766
1714 1767 $params = array(
1715 1768 'wpid' => $website->id,
1716 1769 'where_extra' => ' AND dismiss = 0 ',
1717 - 'order' => ! empty( $args['order'] ) ? sanitize_text_field( wp_unslash( $args['order'] ) ) : '',
1718 - 'order_by' => ! empty( $args['orderby'] ) ? sanitize_text_field( wp_unslash( $args['orderby'] ) ) : '',
1719 - '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',
1720 1773 );
1721 1774
1722 1775 if ( ! empty( $args['paged'] ) && ! empty( $args['items_per_page'] ) ) {
1723 1776 $params['rowcount'] = intval( $args['items_per_page'] );
@@ -1728,10 +1781,10 @@
1728 1781 $params['rowcount'] = $limit;
1729 1782 $params['offset'] = 0;
1730 1783 }
1731 1784
1732 - $params['actions'] = ! empty( $request['actions'] ) ? sanitize_text_field( wp_unslash( $request['actions'] ) ) : '';
1733 - $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'] ) : '';
1734 1787 $params['total_count'] = ! empty( $request['total_count'] ) ? mainwp_string_to_bool( $request['total_count'] ) : false;
1735 1788 $params['optimize_view'] = ! empty( $request['optimize_view'] ) ? mainwp_string_to_bool( $request['optimize_view'] ) : false;
1736 1789 $params['optimize_with_meta'] = ! empty( $request['optimize_with_meta'] ) ? mainwp_string_to_bool( $request['optimize_with_meta'] ) : false;
1737 1790
@@ -2336,9 +2389,9 @@
2336 2389 'arg_options' => array(
2337 2390 'sanitize_callback' => 'sanitize_text_field',
2338 2391 ),
2339 2392 ),
2340 - 'tags' => array( // response as tags.
2393 + 'tags' => array(
2341 2394 'type' => 'string',
2342 2395 'description' => __( 'Site tags.', 'mainwp' ),
2343 2396 'context' => array( 'view' ),
2344 2397 ),
@@ -2544,12 +2597,12 @@
2544 2597 // protection on read responses is provided by
2545 2598 // strip_never_in_response_fields() in
2546 2599 // prepare_site_item_for_response_context(), which strips these
2547 2600 // fields from every site response regardless of context or
2548 - // _fields. (check_permissions() in the auth layer does not
2601 + // _fields. check_permissions() in the auth layer does not
2549 2602 // currently gate ?context=edit to actual edit-permission keys,
2550 2603 // and no caller forwards request context to the schema-filter
2551 - // step, so the schema label alone is not the effective guard.)
2604 + // step, so the schema label alone is not the effective guard.
2552 2605 'http_user' => array(
2553 2606 'type' => 'string',
2554 2607 'description' => __( 'HTTP user', 'mainwp' ),
2555 2608 'context' => array( 'edit' ),
@@ -2612,9 +2665,9 @@
2612 2665 ),
2613 2666 'force_use_ipv4' => array(
2614 2667 'type' => 'string',
2615 2668 'default' => '',
2616 - 'context' => array( 'view' ),
2669 + 'context' => array( 'view', 'edit' ),
2617 2670 ),
2618 2671 'ssl_version' => array(
2619 2672 'type' => 'string',
2620 2673 'default' => '',
@@ -2629,8 +2682,15 @@
2629 2682 'arg_options' => array(
2630 2683 'sanitize_callback' => 'absint',
2631 2684 ),
2632 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 + ),
2633 2693
2634 2694 ),
2635 2695 );
2636 2696 }
@@ -2636,8 +2696,252 @@
2636 2696 }
2637 2697
2638 2698
2639 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 + /**
2640 2944 * Prepare a single order for create or update.
2641 2945 *
2642 2946 * @throws MainWP_Rest_Data_Exception When fails to set any item.
2643 2947 * @param WP_REST_Request $request Request object.
@@ -2643,20 +2947,32 @@
2643 2947 * @param WP_REST_Request $request Request object.
2644 2948 * @return array
2645 2949 */
2646 2950 protected function prepare_object_for_database( $request ) {
2647 - $item_fields = array();
2648 - $item_fields['url'] = isset( $request['url'] ) ? sanitize_text_field( wp_unslash( $request['url'] ) ) : '';
2649 - $item_fields['name'] = isset( $request['name'] ) ? sanitize_text_field( wp_unslash( $request['name'] ) ) : '';
2650 - $item_fields['wpadmin'] = isset( $request['admin'] ) ? sanitize_text_field( wp_unslash( $request['admin'] ) ) : '';
2651 - $item_fields['adminpwd'] = isset( $request['adminpassword'] ) ? wp_unslash( $request['adminpassword'] ) : '';
2652 - $item_fields['unique_id'] = isset( $request['uniqueid'] ) ? sanitize_text_field( wp_unslash( $request['uniqueid'] ) ) : '';
2653 - $item_fields['ssl_verify'] = empty( $request['ssl_verify'] ) ? false : intval( $request['ssl_verify'] );
2654 - $item_fields['force_use_ipv4'] = isset( $request['force_use_ipv4'] ) && mainwp_string_to_bool( $request['force_use_ipv4'] ) ? 1 : 0;
2655 - $item_fields['http_user'] = isset( $request['http_user'] ) ? sanitize_text_field( wp_unslash( $request['http_user'] ) ) : '';
2656 - $item_fields['http_pass'] = isset( $request['http_pass'] ) ? wp_unslash( $request['http_pass'] ) : '';
2657 - $item_fields['groupids'] = isset( $request['groupids'] ) && ! empty( $request['groupids'] ) ? explode( ',', sanitize_text_field( wp_unslash( $request['groupids'] ) ) ) : array();
2658 - $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;
2659 2975
2660 2976 /**
2661 2977 * Filters an object before it is inserted via the REST API.
2662 2978 *
@@ -2697,15 +3013,25 @@
2697 3013 'monitoring_emails' => 'monitoring_emails',
2698 3014 );
2699 3015
2700 3016 $data = array();
2701 - 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 ) {
2702 3021 if ( isset( $request[ $field ] ) ) {
2703 3022 $data[ $field ] = $request[ $field ];
3023 + } elseif ( isset( $request[ $schema_field ] ) ) {
3024 + $data[ $field ] = $request[ $schema_field ];
2704 3025 }
2705 3026 }
2706 3027
2707 - $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 + }
2708 3034
2709 3035 /**
2710 3036 * Filters an object before it is inserted via the REST API.
2711 3037 *
@@ -2852,9 +3178,8 @@
2852 3178
2853 3179 $map_fields = array(
2854 3180 'database_size' => 'dbsize',
2855 3181 'http_status' => 'http_response_code',
2856 - 'health_score' => 'health_value',
2857 3182 'icon' => 'cust_site_icon_info',
2858 3183 'last_sync' => 'dtsSync',
2859 3184 'last_post_time' => 'last_post_gmt',
2860 3185 );
@@ -2864,8 +3189,20 @@
2864 3189 if ( in_array( $field1, $fields ) && property_exists( $item, $field2 ) ) {
2865 3190 $data[ $field1 ] = $item->{$field2};
2866 3191 }
2867 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 + }
2868 3205 }
2869 3206
2870 3207 $website_info = MainWP_DB::instance()->get_website_option( $item->id, 'site_info' );
2871 3208 $website_info = ! empty( $website_info ) ? json_decode( $website_info, true ) : array();
@@ -2930,9 +3267,9 @@
2930 3267 if ( isset( $data['last_sync'] ) ) {
2931 3268 $data['last_sync'] = mainwp_rest_prepare_date_response( $data['last_sync'] );
2932 3269 }
2933 3270
2934 - $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 );
2935 3272
2936 3273 if ( is_array( $custom_fields ) && ! empty( $custom_fields ) ) {
2937 3274 foreach ( $custom_fields as $field ) {
2938 3275 if ( property_exists( $item, $field ) && ! isset( $data[ $field ] ) ) {
@@ -2964,12 +3301,22 @@
2964 3301 public function create_item( $request ) { //phpcs:ignore -- NOSONAR - complex.
2965 3302 $resp_data = array();
2966 3303 $resp_data['success'] = 0;
2967 3304 $site_id = 0;
2968 - $url = '';
2969 3305 $data = array(); //phpcs:ignore -- NOSONAR - not used data.
2970 3306 $found_id = 0;
2971 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 +
2972 3319 $item = $this->prepare_object_for_database( $request );
2973 3320
2974 3321 if ( empty( $item['name'] ) && ! empty( $item['url'] ) ) {
2975 3322 $item['name'] = MainWP_Utility::remove_http_prefix( $item['url'], true );
@@ -2975,10 +3322,9 @@
2975 3322 $item['name'] = MainWP_Utility::remove_http_prefix( $item['url'], true );
2976 3323 $item['name'] = rtrim( $item['name'], '/' );
2977 3324 }
2978 3325
2979 - $url = $item['url'];
2980 - $website = MainWP_DB::instance()->get_websites_by_url( $item['url'] );
3326 + $website = MainWP_DB::instance()->get_websites_by_url( $item['url'] );
2981 3327 list( $message, $error, $site_id, $found_id ) = MainWP_Manage_Sites_View::add_wp_site( $website, $item );
2982 3328
2983 3329 if ( ! empty( $site_id ) ) {
2984 3330 $site = MainWP_DB::instance()->get_website_by_id( $site_id );
@@ -3015,13 +3361,41 @@
3015 3361 $website = $this->get_request_item( $request );
3016 3362 if ( is_wp_error( $website ) ) {
3017 3363 return $website;
3018 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 +
3019 3386 $resp_data = array();
3020 3387 $resp_data['success'] = 0;
3021 3388 try {
3022 3389 $item = $this->prepare_object_for_update( $request );
3023 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 + }
3024 3398 if ( is_array( $result ) && ! empty( $result['success'] ) ) {
3025 3399 $resp_data['success'] = 1;
3026 3400 $params = array(
3027 3401 'full_data' => true,
@@ -3031,9 +3405,9 @@
3031 3405 );
3032 3406 $websites = MainWP_DB::instance()->get_websites_for_current_user( $params );
3033 3407 $data = $websites ? current( $websites ) : array();
3034 3408 $resp_data['data'] = $this->prepare_site_item_for_response_context( $data, $request, 'view' );
3035 - } else {
3409 + } elseif ( empty( $resp_data['group_message'] ) && empty( $resp_data['message'] ) ) {
3036 3410 $resp_data['error'] = esc_html__( 'Update site failed. Please try again.', 'mainwp' );
3037 3411 }
3038 3412 } catch ( \Exception $e ) {
3039 3413 $resp_data['error'] = $e->getMessage();