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 +234 -40 6.1.7 → 6.2.2 View file →
@@ -769,12 +769,14 @@
769 769 }
770 770
771 771 $data = array();
772 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.
773 775 $record = array(
774 - 'field_id' => (int) $field->field_id ? $field->field_id : 0,
775 - 'name' => $field->field_name ? $field->field_name : '',
776 - '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 : '',
777 779 );
778 780 $data[] = $this->filter_response_data_by_allowed_fields( $record, 'field_view' );
779 781 }
780 782
@@ -809,28 +811,57 @@
809 811 * @return WP_Error|WP_REST_Response
810 812 */
811 813 public function create_client_fields( $request ) {
812 814 // Get request body.
813 - $body = $request->get_body_params();
814 - 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 ) {
815 846 return new WP_Error(
816 - 'empty_body',
817 - __( 'Request body is empty.', 'mainwp' ),
847 + 'invalid_field_value',
848 + __( 'Name must be 191 characters or fewer.', 'mainwp' ),
849 + array( 'status' => 400 )
818 850 );
819 851 }
820 852
821 - // Validate request body.
822 - 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 ) {
823 857 return new WP_Error(
824 - 'empty_name',
825 - __( 'Name and description are required.', 'mainwp' ),
858 + 'invalid_field_value',
859 + __( 'Description must be 255 characters or fewer.', 'mainwp' ),
860 + array( 'status' => 400 )
826 861 );
827 862 }
828 863
829 - $client_id = 0;
830 - $name = sanitize_text_field( wp_unslash( $body['name'] ) );
831 - $desc = sanitize_text_field( wp_unslash( $body['description'] ) );
832 -
833 864 $field = MainWP_DB_Client::instance()->add_client_field(
834 865 array(
835 866 'field_name' => $name,
836 867 'field_desc' => $desc,
@@ -838,19 +869,27 @@
838 869 )
839 870 );
840 871
841 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 +
842 879 return new WP_Error(
843 880 'create_field_failed',
844 - __( '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 )
845 883 );
846 884 }
847 885
848 - // 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.
849 888 $data = array(
850 - 'field_id' => (int) $field->field_id ? $field->field_id : 0,
851 - 'name' => $field->field_name ? $field->field_name : '',
852 - '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 : '',
853 892 );
854 893
855 894 return rest_ensure_response(
856 895 array(
@@ -876,35 +915,75 @@
876 915 if ( empty( $field ) ) {
877 916 return new WP_Error(
878 917 'invalid_field_id',
879 918 __( 'Invalid client field.', 'mainwp' ),
919 + array( 'status' => 404 )
880 920 );
881 921 }
882 922
883 923 // Get request body.
884 - $body = $request->get_json_params();
885 - 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 ) {
886 945 return new WP_Error(
887 - 'empty_body',
888 - __( 'Request body is empty.', 'mainwp' ),
946 + 'invalid_field_value',
947 + __( 'Name must be 191 characters or fewer.', 'mainwp' ),
948 + array( 'status' => 400 )
889 949 );
890 950 }
891 951
892 - $name = ! empty( $body['name'] ) ? sanitize_text_field( wp_unslash( $body['name'] ) ) : $field->field_name;
893 - $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 + }
894 962
895 - $updated = MainWP_DB_Client::instance()->update_client_field(
896 - $field->field_id,
897 - array(
898 - 'field_name' => $name,
899 - 'field_desc' => $desc,
900 - )
901 - );
902 - if ( ! $updated ) {
903 - return new WP_Error(
904 - 'update_field_failed',
905 - __( '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 + )
906 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 + }
907 986 }
908 987
909 988 return rest_ensure_response(
910 989 array(
@@ -958,19 +1037,26 @@
958 1037 *
959 1038 * @return array
960 1039 */
961 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().
962 1044 return array(
963 1045 'name' => array(
964 1046 'required' => false,
965 1047 'type' => 'string',
1048 + 'maxLength' => 191,
966 1049 'sanitize_callback' => 'sanitize_text_field',
1050 + 'validate_callback' => 'rest_validate_request_arg',
967 1051 'description' => __( 'Field name.', 'mainwp' ),
968 1052 ),
969 1053 'description' => array(
970 1054 'required' => false,
971 1055 'type' => 'string',
1056 + 'maxLength' => 255,
972 1057 'sanitize_callback' => 'sanitize_text_field',
1058 + 'validate_callback' => 'rest_validate_request_arg',
973 1059 'description' => __( 'Field description.', 'mainwp' ),
974 1060 ),
975 1061 );
976 1062 }
@@ -977,22 +1063,28 @@
977 1063
978 1064 /**
979 1065 * Create client fields allowed fields.
980 1066 *
981 - * @return WP_Error|WP_REST_Response
1067 + * @return array
982 1068 */
983 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.
984 1072 return array(
985 1073 'name' => array(
986 1074 'required' => true,
987 1075 'type' => 'string',
1076 + 'maxLength' => 191,
988 1077 'sanitize_callback' => 'sanitize_text_field',
1078 + 'validate_callback' => 'rest_validate_request_arg',
989 1079 'description' => __( 'Field name.', 'mainwp' ),
990 1080 ),
991 1081 'description' => array(
992 1082 'required' => true,
993 1083 'type' => 'string',
1084 + 'maxLength' => 255,
994 1085 'sanitize_callback' => 'sanitize_text_field',
1086 + 'validate_callback' => 'rest_validate_request_arg',
995 1087 'description' => __( 'Field description.', 'mainwp' ),
996 1088 ),
997 1089 );
998 1090 }
@@ -1050,8 +1142,109 @@
1050 1142 );
1051 1143 }
1052 1144
1053 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 + /**
1054 1247 * Get client field request id or name
1055 1248 *
1056 1249 * @param WP_REST_Request $request Full details about the request.
1057 1250 *
@@ -1063,13 +1256,14 @@
1063 1256 $raw = $request->get_param( 'id_name' );
1064 1257 $decoded = rawurldecode( (string) $raw );
1065 1258 $field_value = trim( sanitize_text_field( wp_unslash( $decoded ) ) );
1066 1259
1067 - if ( empty( $field_value ) ) {
1260 + if ( '' === $field_value ) {
1068 1261 return false;
1069 1262 }
1070 1263
1071 - // 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.
1072 1266 if ( ctype_digit( $field_value ) ) {
1073 1267 return MainWP_DB_Client::instance()->get_client_fields_by( 'field_id', (int) $field_value );
1074 1268 }
1075 1269