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-clients-controller.php +250 -41 6.1 → 6.2.2 View file →
@@ -741,8 +741,13 @@
741 741 *
742 742 * @return WP_Error|WP_REST_Response
743 743 */
744 744 public function client_fields( $request ) { // phpcs:ignore -- NOSONAR - complex.
745 + // pre_page shipped as the documented page size but was never read; keep it working through per_page.
746 + if ( null === $request->get_param( 'per_page' ) && null !== $request->get_param( 'pre_page' ) ) {
747 + $request->set_param( 'per_page', $request->get_param( 'pre_page' ) );
748 + }
749 +
745 750 // Prepare query args.
746 751 $args = $this->prepare_objects_query( $request, 'client_fields' );
747 752 $page = ! empty( $args['paged'] ) ? (int) $args['paged'] : 1;
748 753 $per_page = ! empty( $args['items_per_page'] ) ? (int) $args['items_per_page'] : 20;
@@ -764,12 +769,14 @@
764 769 }
765 770
766 771 $data = array();
767 772 foreach ( $fields as $field ) {
773 + // A stored "0" is reported as the value it is, not as an empty one. field_id is cast
774 + // because wpdb answers every column as a string and the schema declares an integer.
768 775 $record = array(
769 - 'field_id' => (int) $field->field_id ? $field->field_id : 0,
770 - 'name' => $field->field_name ? $field->field_name : '',
771 - 'description' => $field->field_desc ? $field->field_desc : '',
776 + 'field_id' => isset( $field->field_id ) ? (int) $field->field_id : 0,
777 + 'name' => isset( $field->field_name ) ? $field->field_name : '',
778 + 'description' => isset( $field->field_desc ) ? $field->field_desc : '',
772 779 );
773 780 $data[] = $this->filter_response_data_by_allowed_fields( $record, 'field_view' );
774 781 }
775 782
@@ -804,28 +811,57 @@
804 811 * @return WP_Error|WP_REST_Response
805 812 */
806 813 public function create_client_fields( $request ) {
807 814 // Get request body.
808 - $body = $request->get_body_params();
809 - if ( empty( $body ) ) {
815 + $body = $this->get_request_body( $request );
816 + if ( is_wp_error( $body ) ) {
817 + return $body;
818 + }
819 +
820 + $invalid = $this->validate_client_field_values( $body );
821 + if ( is_wp_error( $invalid ) ) {
822 + return $invalid;
823 + }
824 +
825 + // Validate request body. "0" is a value the column stores, so a required value is one whose
826 + // key was sent carrying something other than whitespace, not one that passes a falsy test.
827 + // validate_client_field_values() has already refused a key sent as anything but a string.
828 + foreach ( array( 'name', 'description' ) as $required ) {
829 + if ( ! isset( $body[ $required ] ) || '' === trim( $body[ $required ] ) ) {
830 + return new WP_Error(
831 + 'empty_name',
832 + __( 'Name and description are required.', 'mainwp' ),
833 + array( 'status' => 400 )
834 + );
835 + }
836 + }
837 +
838 + $client_id = 0;
839 + $name = sanitize_text_field( wp_unslash( $body['name'] ) );
840 + $desc = sanitize_text_field( wp_unslash( $body['description'] ) );
841 +
842 + // field_name is varchar(191), so a longer name is truncated by wpdb before the unique index sees
843 + // it and the duplicate lookup, which queries the untruncated name, would not recognise the refusal.
844 + // A body read as raw JSON skips the registered maxLength, so the limit is enforced here as well.
845 + if ( mb_strlen( $name ) > 191 ) {
810 846 return new WP_Error(
811 - 'empty_body',
812 - __( 'Request body is empty.', 'mainwp' ),
847 + 'invalid_field_value',
848 + __( 'Name must be 191 characters or fewer.', 'mainwp' ),
849 + array( 'status' => 400 )
813 850 );
814 851 }
815 852
816 - // Validate request body.
817 - if ( empty( $body['name'] ) || empty( $body['description'] ) ) {
853 + // field_desc is varchar(255), so a longer description is either refused by the write or stored
854 + // truncated depending on the server's strict mode, and neither answers what the caller sent.
855 + // A body read as raw JSON skips the registered maxLength, so the limit is enforced here as well.
856 + if ( mb_strlen( $desc ) > 255 ) {
818 857 return new WP_Error(
819 - 'empty_name',
820 - __( 'Name and description are required.', 'mainwp' ),
858 + 'invalid_field_value',
859 + __( 'Description must be 255 characters or fewer.', 'mainwp' ),
860 + array( 'status' => 400 )
821 861 );
822 862 }
823 863
824 - $client_id = 0;
825 - $name = sanitize_text_field( wp_unslash( $body['name'] ) );
826 - $desc = sanitize_text_field( wp_unslash( $body['description'] ) );
827 -
828 864 $field = MainWP_DB_Client::instance()->add_client_field(
829 865 array(
830 866 'field_name' => $name,
831 867 'field_desc' => $desc,
@@ -833,19 +869,27 @@
833 869 )
834 870 );
835 871
836 872 if ( ! $field ) {
873 + // add_client_field() returns false for a name it refuses to store as well as for a failed
874 + // insert, so a name left empty by sanitizing or already taken stays a client error and
875 + // everything else is a server failure.
876 + $taken = $this->client_field_name_taken( $name, $client_id );
877 + $caller_fault = ( '' === $name || $taken );
878 +
837 879 return new WP_Error(
838 880 'create_field_failed',
839 - __( 'Create client field failed.', 'mainwp' ),
881 + $caller_fault ? __( 'Create client field failed.', 'mainwp' ) : __( 'Creating the client field failed.', 'mainwp' ),
882 + array( 'status' => $caller_fault ? 400 : 500 )
840 883 );
841 884 }
842 885
843 - // Prepare response data.
886 + // Prepare response data. A stored "0" is reported as the value it is, not as an empty one.
887 + // field_id is cast because wpdb answers every column as a string and the schema declares an integer.
844 888 $data = array(
845 - 'field_id' => (int) $field->field_id ? $field->field_id : 0,
846 - 'name' => $field->field_name ? $field->field_name : '',
847 - 'description' => $field->field_desc ? $field->field_desc : '',
889 + 'field_id' => isset( $field->field_id ) ? (int) $field->field_id : 0,
890 + 'name' => isset( $field->field_name ) ? $field->field_name : '',
891 + 'description' => isset( $field->field_desc ) ? $field->field_desc : '',
848 892 );
849 893
850 894 return rest_ensure_response(
851 895 array(
@@ -871,35 +915,75 @@
871 915 if ( empty( $field ) ) {
872 916 return new WP_Error(
873 917 'invalid_field_id',
874 918 __( 'Invalid client field.', 'mainwp' ),
919 + array( 'status' => 404 )
875 920 );
876 921 }
877 922
878 923 // Get request body.
879 - $body = $request->get_json_params();
880 - if ( empty( $body ) ) {
924 + $body = $this->get_request_body( $request );
925 + if ( is_wp_error( $body ) ) {
926 + return $body;
927 + }
928 +
929 + $invalid = $this->validate_client_field_values( $body );
930 + if ( is_wp_error( $invalid ) ) {
931 + return $invalid;
932 + }
933 +
934 + // Only a key left out, or sent with nothing but whitespace, keeps the stored value. The add
935 + // route reads a value the same way, and sanitizing trims whitespace away, so a value tested
936 + // untrimmed here would sanitize down to an empty string the DB layer refuses. "0" is a value
937 + // the column stores, and a falsy test would drop it and answer that the edit succeeded.
938 + $name = isset( $body['name'] ) && '' !== trim( $body['name'] ) ? sanitize_text_field( wp_unslash( $body['name'] ) ) : $field->field_name;
939 + $desc = isset( $body['description'] ) && '' !== trim( $body['description'] ) ? sanitize_text_field( wp_unslash( $body['description'] ) ) : $field->field_desc;
940 +
941 + // field_name is varchar(191), so a longer name is truncated by wpdb before the unique index sees
942 + // it and the duplicate lookup, which queries the untruncated name, would not recognise the refusal.
943 + // A body read as raw JSON skips the registered maxLength, so the limit is enforced here as well.
944 + if ( mb_strlen( $name ) > 191 ) {
881 945 return new WP_Error(
882 - 'empty_body',
883 - __( 'Request body is empty.', 'mainwp' ),
946 + 'invalid_field_value',
947 + __( 'Name must be 191 characters or fewer.', 'mainwp' ),
948 + array( 'status' => 400 )
884 949 );
885 950 }
886 951
887 - $name = ! empty( $body['name'] ) ? sanitize_text_field( wp_unslash( $body['name'] ) ) : $field->field_name;
888 - $desc = ! empty( $body['description'] ) ? sanitize_text_field( wp_unslash( $body['description'] ) ) : $field->field_desc;
952 + // field_desc is varchar(255), so a longer description is either refused by the write or stored
953 + // truncated depending on the server's strict mode, and neither answers what the caller sent.
954 + // A body read as raw JSON skips the registered maxLength, so the limit is enforced here as well.
955 + if ( mb_strlen( $desc ) > 255 ) {
956 + return new WP_Error(
957 + 'invalid_field_value',
958 + __( 'Description must be 255 characters or fewer.', 'mainwp' ),
959 + array( 'status' => 400 )
960 + );
961 + }
889 962
890 - $updated = MainWP_DB_Client::instance()->update_client_field(
891 - $field->field_id,
892 - array(
893 - 'field_name' => $name,
894 - 'field_desc' => $desc,
895 - )
896 - );
897 - if ( ! $updated ) {
898 - return new WP_Error(
899 - 'update_field_failed',
900 - __( 'Field already exists, try different field name.', 'mainwp' ),
963 + // $wpdb->update() reports no changed rows for an edit that stores the values already there, and
964 + // the DB layer cannot tell that apart from a failure, so an edit that changes nothing is not run.
965 + if ( $name !== $field->field_name || $desc !== $field->field_desc ) {
966 + $updated = MainWP_DB_Client::instance()->update_client_field(
967 + $field->field_id,
968 + array(
969 + 'field_name' => $name,
970 + 'field_desc' => $desc,
971 + )
901 972 );
973 + if ( ! $updated ) {
974 + // The DB layer refuses a name left empty by sanitizing, and the unique index on
975 + // (client_id, field_name) refuses a rename onto a name another field of this client
976 + // already owns. Both are the caller's fault; anything else is the write itself failing.
977 + $owned = $this->client_field_name_taken( $name, $field->client_id, $field->field_id );
978 + $caller_fault = ( '' === $name || $owned );
979 +
980 + return new WP_Error(
981 + 'update_field_failed',
982 + $caller_fault ? __( 'Field already exists, try different field name.', 'mainwp' ) : __( 'Updating the client field failed.', 'mainwp' ),
983 + array( 'status' => $caller_fault ? 400 : 500 )
984 + );
985 + }
902 986 }
903 987
904 988 return rest_ensure_response(
905 989 array(
@@ -953,19 +1037,26 @@
953 1037 *
954 1038 * @return array
955 1039 */
956 1040 public function edit_client_fields_allowed_fields() {
1041 + // WordPress only runs the type check its own sanitizer carries, and a registered
1042 + // sanitize_callback replaces it, so without an explicit validate_callback a JSON 42 or true
1043 + // would reach the handler already cast to text by sanitize_text_field().
957 1044 return array(
958 1045 'name' => array(
959 1046 'required' => false,
960 1047 'type' => 'string',
1048 + 'maxLength' => 191,
961 1049 'sanitize_callback' => 'sanitize_text_field',
1050 + 'validate_callback' => 'rest_validate_request_arg',
962 1051 'description' => __( 'Field name.', 'mainwp' ),
963 1052 ),
964 1053 'description' => array(
965 1054 'required' => false,
966 1055 'type' => 'string',
1056 + 'maxLength' => 255,
967 1057 'sanitize_callback' => 'sanitize_text_field',
1058 + 'validate_callback' => 'rest_validate_request_arg',
968 1059 'description' => __( 'Field description.', 'mainwp' ),
969 1060 ),
970 1061 );
971 1062 }
@@ -972,22 +1063,28 @@
972 1063
973 1064 /**
974 1065 * Create client fields allowed fields.
975 1066 *
976 - * @return WP_Error|WP_REST_Response
1067 + * @return array
977 1068 */
978 1069 public function create_client_fields_allowed_fields() {
1070 + // See edit_client_fields_allowed_fields(): the registered sanitizer displaces the type check
1071 + // WordPress would otherwise run, so the declared type needs its own validate_callback.
979 1072 return array(
980 1073 'name' => array(
981 1074 'required' => true,
982 1075 'type' => 'string',
1076 + 'maxLength' => 191,
983 1077 'sanitize_callback' => 'sanitize_text_field',
1078 + 'validate_callback' => 'rest_validate_request_arg',
984 1079 'description' => __( 'Field name.', 'mainwp' ),
985 1080 ),
986 1081 'description' => array(
987 1082 'required' => true,
988 1083 'type' => 'string',
1084 + 'maxLength' => 255,
989 1085 'sanitize_callback' => 'sanitize_text_field',
1086 + 'validate_callback' => 'rest_validate_request_arg',
990 1087 'description' => __( 'Field description.', 'mainwp' ),
991 1088 ),
992 1089 );
993 1090 }
@@ -1023,20 +1120,131 @@
1023 1120 'minimum' => 1,
1024 1121 'sanitize_callback' => 'absint',
1025 1122 'description' => __( 'Page number.', 'mainwp' ),
1026 1123 ),
1124 + 'per_page' => array(
1125 + 'required' => false,
1126 + 'type' => 'integer',
1127 + 'sanitize_callback' => 'absint',
1128 + 'validate_callback' => 'rest_validate_request_arg',
1129 + 'minimum' => 1,
1130 + 'maximum' => 200,
1131 + 'description' => __( 'Number of client fields per page.', 'mainwp' ),
1132 + ),
1027 1133 'pre_page' => array(
1028 1134 'required' => false,
1029 1135 'type' => 'integer',
1030 1136 'sanitize_callback' => 'absint',
1137 + 'validate_callback' => 'rest_validate_request_arg',
1031 1138 'minimum' => 1,
1032 1139 'maximum' => 200,
1033 - 'description' => __( 'Number of client fields per page.', 'mainwp' ),
1140 + 'description' => __( 'Deprecated alias of per_page. Number of client fields per page.', 'mainwp' ),
1034 1141 ),
1035 1142 );
1036 1143 }
1037 1144
1038 1145 /**
1146 + * Get request body.
1147 + *
1148 + * @param WP_REST_Request $request Full details about the request.
1149 + *
1150 + * @return array|WP_Error Body payload, or an error when the request carries none.
1151 + */
1152 + private function get_request_body( $request ) {
1153 + // Get request body from form-encoded senders.
1154 + $body = $request->get_body_params();
1155 + if ( ! empty( $body ) && is_array( $body ) ) {
1156 + return $body;
1157 + }
1158 +
1159 + // Get request body from JSON senders.
1160 + $body = $request->get_json_params();
1161 + if ( ! empty( $body ) && is_array( $body ) ) {
1162 + return $body;
1163 + }
1164 +
1165 + // Get request body from raw, for JSON sent without the JSON content type.
1166 + $body = $request->get_body();
1167 + if ( ! empty( $body ) && is_string( $body ) ) {
1168 + $body = json_decode( $body, true );
1169 + if ( is_array( $body ) && ! empty( $body ) ) {
1170 + return $body;
1171 + }
1172 + }
1173 +
1174 + return new WP_Error(
1175 + 'empty_body',
1176 + __( 'Request body is empty.', 'mainwp' ),
1177 + array( 'status' => 400 )
1178 + );
1179 + }
1180 +
1181 + /**
1182 + * Validate the client field text values carried by a request body.
1183 + *
1184 + * @param array $body Request body.
1185 + *
1186 + * @return WP_Error|true Error when a value cannot be stored as text.
1187 + */
1188 + private function validate_client_field_values( $body ) {
1189 + foreach ( array( 'name', 'description' ) as $key ) {
1190 + // A body read as raw JSON skips the registered sanitizers, so a value that is not already
1191 + // text reaches sanitize_text_field() and is stored as a cast of itself or as an empty
1192 + // string, wiping what it meant to set. A key sent as null counts as sent, not as omitted.
1193 + if ( array_key_exists( $key, $body ) && ! is_string( $body[ $key ] ) ) {
1194 + return new WP_Error(
1195 + 'invalid_field_value',
1196 + __( 'Name and description must be text values.', 'mainwp' ),
1197 + array( 'status' => 400 )
1198 + );
1199 + }
1200 + }
1201 +
1202 + return true;
1203 + }
1204 +
1205 + /**
1206 + * Whether a client already has a field stored under the given name.
1207 + *
1208 + * @param string $name Field name as the handler sanitized it before the write.
1209 + * @param int $client_id Client the field belongs to.
1210 + * @param int $exclude_id Field id to skip, for a rename that keeps its own name.
1211 + *
1212 + * @uses MainWP_DB_Client::instance()->get_client_fields_by_params()
1213 + *
1214 + * @return bool
1215 + */
1216 + private function client_field_name_taken( $name, $client_id, $exclude_id = 0 ) {
1217 + // A field_name lookup strips [ and ] from the value it queries with while the stored name keeps
1218 + // them, and a PHP comparison would call two names that differ only in case distinct while the
1219 + // column collation and the unique index call them the same. The match is left to the database
1220 + // on the stored value so this answer agrees with the index that refused the write.
1221 + $client_id = (int) $client_id;
1222 + $fields = MainWP_DB_Client::instance()->get_client_fields_by_params(
1223 + array(
1224 + 'client_id' => $client_id,
1225 + 'field_name_exact' => (string) $name,
1226 + )
1227 + );
1228 +
1229 + if ( ! is_array( $fields ) ) {
1230 + return false;
1231 + }
1232 +
1233 + foreach ( $fields as $existing ) {
1234 + // Client 0 holds the general fields this route writes, and 0 is not a value the query filters
1235 + // on, so the owner check happens here.
1236 + if ( (int) $existing->client_id !== $client_id || (int) $existing->field_id === (int) $exclude_id ) {
1237 + continue;
1238 + }
1239 +
1240 + return true;
1241 + }
1242 +
1243 + return false;
1244 + }
1245 +
1246 + /**
1039 1247 * Get client field request id or name
1040 1248 *
1041 1249 * @param WP_REST_Request $request Full details about the request.
1042 1250 *
@@ -1048,13 +1256,14 @@
1048 1256 $raw = $request->get_param( 'id_name' );
1049 1257 $decoded = rawurldecode( (string) $raw );
1050 1258 $field_value = trim( sanitize_text_field( wp_unslash( $decoded ) ) );
1051 1259
1052 - if ( empty( $field_value ) ) {
1260 + if ( '' === $field_value ) {
1053 1261 return false;
1054 1262 }
1055 1263
1056 - // Get client field by id.
1264 + // Get client field by id. A digit-only segment is an id, so "0" and any other all-digit name is
1265 + // addressable by its own id only, never by name.
1057 1266 if ( ctype_digit( $field_value ) ) {
1058 1267 return MainWP_DB_Client::instance()->get_client_fields_by( 'field_id', (int) $field_value );
1059 1268 }
1060 1269