PluginProbe
MainWP Dashboard: Self-hosted WordPress Management for Agencies / 6.3
MainWP Dashboard: Self-hosted WordPress Management for Agencies v6.3
6.3 6.2.3 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 All 157 releases
← All changes | includes/rest-api/controller/version2/class-mainwp-rest-clients-controller.php +312 -54 6.1.7 → 6.3 View file →
@@ -298,9 +298,9 @@
298 298 array(
299 299 array(
300 300 'methods' => WP_REST_Server::CREATABLE,
301 301 'callback' => array( $this, 'create_client_fields' ),
302 - 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
302 + 'permission_callback' => array( $this, 'get_unrestricted_permissions_check' ),
303 303 'args' => $this->create_client_fields_allowed_fields(),
304 304 ),
305 305 )
306 306 );
@@ -311,9 +311,9 @@
311 311 array(
312 312 array(
313 313 'methods' => 'PUT, PATCH',
314 314 'callback' => array( $this, 'edit_client_fields' ),
315 - 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
315 + 'permission_callback' => array( $this, 'get_unrestricted_permissions_check' ),
316 316 'args' => $this->edit_client_fields_allowed_fields(),
317 317 ),
318 318 )
319 319 );
@@ -323,9 +323,9 @@
323 323 array(
324 324 array(
325 325 'methods' => WP_REST_Server::DELETABLE,
326 326 'callback' => array( $this, 'delete_client_fields' ),
327 - 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
327 + 'permission_callback' => array( $this, 'get_unrestricted_permissions_check' ),
328 328 ),
329 329 )
330 330 );
331 331 }
@@ -333,12 +333,13 @@
333 333 /**
334 334 * Get site by.
335 335 *
336 336 * @param WP_REST_Request $request Full details about the request.
337 + * @param bool $require_full_coverage Whether all client sites must be accessible.
337 338 *
338 339 * @return WP_Error|bool|Object Item.
339 340 */
340 - public function get_request_item( $request ) {
341 + public function get_request_item( $request, $require_full_coverage = false ) {
341 342 $route = $request->get_route();
342 343
343 344 if ( MainWP_Utility::string_ends_by( $route, '/batch' ) ) {
344 345 $by = 'id';
@@ -351,12 +352,43 @@
351 352 } else {
352 353 $value = wp_unslash( $value );
353 354 }
354 355 }
355 - return $this->get_client_by( $by, $value );
356 + $client = $this->get_client_by( $by, $value );
357 + if ( ! is_wp_error( $client ) && $require_full_coverage && 'all' !== \MainWP\Dashboard\MainWP_Abilities_Util::get_scoped_site_ids_or_all() ) {
358 + $access = \MainWP\Dashboard\MainWP_Abilities_Util::check_client_access( $client, array( 'client_id_or_email' => $value ), true );
359 + if ( is_wp_error( $access ) ) {
360 + return $access;
361 + }
362 + }
363 + return $client;
356 364 }
357 365
358 366 /**
367 + * Replacing a client's roster must preserve memberships hidden from the caller.
368 + *
369 + * @param array $data Client fields, including selected_sites when supplied.
370 + * @param int $client_id Existing client ID, or zero for creation.
371 + * @return true|WP_Error
372 + */
373 + protected function prepare_client_sites( &$data, $client_id = 0 ) {
374 + $allowed = \MainWP\Dashboard\MainWP_Abilities_Util::get_scoped_site_ids_or_all();
375 + if ( 'all' === $allowed || ! isset( $data['selected_sites'] ) ) {
376 + return true;
377 + }
378 + $access = \MainWP\Dashboard\MainWP_Abilities_Clients::check_selected_sites_scope( $data );
379 + if ( is_wp_error( $access ) ) {
380 + return $access;
381 + }
382 + if ( $client_id ) {
383 + $sites = MainWP_DB_Client::instance()->get_websites_by_client_ids( $client_id, array( 'no_perm_check' => true ) );
384 + $unseen = array_diff( array_map( static fn( $site ) => (int) $site->id, (array) $sites ), $allowed );
385 + $data['selected_sites'] = array_values( array_unique( array_merge( array_map( 'intval', $data['selected_sites'] ), $unseen ) ) );
386 + }
387 + return true;
388 + }
389 +
390 + /**
359 391 * Get the query params for collections.
360 392 *
361 393 * @return array
362 394 */
@@ -374,8 +406,12 @@
374 406 *
375 407 * @return array
376 408 */
377 409 protected function get_formatted_item_data( $data ) {
410 + $allowed = \MainWP\Dashboard\MainWP_Abilities_Util::get_scoped_site_ids_or_all();
411 + if ( is_array( $allowed ) && isset( $data['selected_sites'] ) ) {
412 + $data['selected_sites'] = implode( ',', array_intersect( wp_parse_id_list( $data['selected_sites'] ), $allowed ) );
413 + }
378 414 if ( ! empty( $data['created'] ) ) {
379 415 $data['created'] = mainwp_rest_prepare_date_response( $data['created'] );
380 416 }
381 417 if ( isset( $data['client_id'] ) && ! isset( $data['field_id'] ) ) {
@@ -443,18 +479,24 @@
443 479 'with_tags' => isset( $request['with_tags'] ) && $request['with_tags'] ? true : false,
444 480 'with_contacts' => isset( $request['with_contacts'] ) && $request['with_contacts'] ? true : false,
445 481 );
446 482
447 - if ( false !== $page ) {
483 + $allowed = \MainWP\Dashboard\MainWP_Abilities_Util::get_scoped_site_ids_or_all();
484 + if ( 'all' === $allowed && false !== $page ) {
448 485 $prepared_args['page'] = $page;
449 486 }
450 - if ( false !== $per_page ) {
487 + if ( 'all' === $allowed && false !== $per_page ) {
451 488 $prepared_args['per_page'] = $per_page;
452 489 }
453 490
454 491 // get data.
455 492 $clients = MainWP_DB_Client::instance()->get_wp_clients( $prepared_args );
456 - $data = array();
493 + $clients = $this->filter_scoped_clients( $clients );
494 + if ( 'all' !== $allowed && $page > 0 && $per_page > 0 ) {
495 + // A huge page number would overflow the offset to a float.
496 + $clients = $page - 1 < ceil( count( $clients ) / $per_page ) ? array_slice( $clients, ( $page - 1 ) * $per_page, $per_page ) : array();
497 + }
498 + $data = array();
457 499 if ( is_array( $clients ) ) {
458 500 foreach ( $clients as $client ) {
459 501 $data[] = $this->filter_response_data_by_allowed_fields( $client );
460 502 }
@@ -474,13 +516,17 @@
474 516 * @param WP_REST_Request $request Full details about the request.
475 517 * @return WP_Error|WP_REST_Response
476 518 */
477 519 public function count_items( $request ) {
520 + $allowed = \MainWP\Dashboard\MainWP_Abilities_Util::get_scoped_site_ids_or_all();
478 521 $prepared_args = array(
479 - 'count_only' => true,
522 + 'count_only' => 'all' === $allowed,
480 523 );
481 524 // get data.
482 525 $value = MainWP_DB_Client::instance()->get_wp_clients( $prepared_args );
526 + if ( 'all' !== $allowed ) {
527 + $value = count( $this->filter_scoped_clients( $value ) );
528 + }
483 529 return rest_ensure_response(
484 530 array(
485 531 'success' => 1,
486 532 'total' => $value,
@@ -501,8 +547,12 @@
501 547 $item = array_filter( $request->get_params() );
502 548 if ( isset( $item['selected_sites'] ) ) {
503 549 $item['selected_sites'] = array_unique( wp_parse_list( $item['selected_sites'] ) );
504 550 }
551 + $scope = $this->prepare_client_sites( $item );
552 + if ( is_wp_error( $scope ) ) {
553 + return $scope;
554 + }
505 555 $result = MainWP_Client_Handler::rest_api_add_client( $item );
506 556 if ( is_array( $result ) && isset( $result['client_id'] ) ) {
507 557 $client = MainWP_DB_Client::instance()->get_wp_client_by( 'client_id', $result['client_id'], OBJECT, array( 'with_selected_sites' => true ) );
508 558 $resp_data['success'] = 1;
@@ -537,8 +587,11 @@
537 587 $resp_data = array();
538 588 $resp_data['success'] = 0;
539 589 try {
540 590 $data = array_filter( $request->get_params() );
591 + if ( null !== $request->get_param( 'selected_sites' ) && 'all' !== \MainWP\Dashboard\MainWP_Abilities_Util::get_scoped_site_ids_or_all() ) {
592 + $data['selected_sites'] = $request->get_param( 'selected_sites' );
593 + }
541 594 if ( is_array( $data ) ) {
542 595 $data['client_id'] = $item->client_id;
543 596 }
544 597 if ( isset( $data['selected_sites'] ) ) {
@@ -543,8 +596,12 @@
543 596 }
544 597 if ( isset( $data['selected_sites'] ) ) {
545 598 $data['selected_sites'] = array_unique( wp_parse_list( $data['selected_sites'] ) );
546 599 }
600 + $scope = $this->prepare_client_sites( $data, $item->client_id );
601 + if ( is_wp_error( $scope ) ) {
602 + return $scope;
603 + }
547 604 $result = MainWP_Client_Handler::rest_api_add_client( $data, true );
548 605 if ( is_array( $result ) && isset( $result['client_id'] ) ) {
549 606 $client = MainWP_DB_Client::instance()->get_wp_client_by( 'client_id', $result['client_id'], OBJECT, array( 'with_selected_sites' => true ) );
550 607 $resp_data['success'] = 1;
@@ -568,9 +625,9 @@
568 625 * @param WP_REST_Request $request Full details about the request.
569 626 * @return WP_Error|WP_REST_Response
570 627 */
571 628 public function delete_item( $request ) {
572 - $item = $this->get_request_item( $request );
629 + $item = $this->get_request_item( $request, true );
573 630 if ( is_wp_error( $item ) ) {
574 631 return $item;
575 632 }
576 633
@@ -604,8 +661,9 @@
604 661 'selectgroups' => true,
605 662 'full_data' => true,
606 663 )
607 664 );
665 + $sites = $this->filter_scoped_sites( $sites );
608 666 if ( $sites ) {
609 667 foreach ( $sites as $site ) {
610 668 $data[] = apply_filters( 'mainwp_rest_routes_sites_controller_filter_allowed_fields_by_context', $site );
611 669 }
@@ -635,16 +693,21 @@
635 693 $resp_data = array(
636 694 'success' => 1,
637 695 );
638 696
639 - $client_costs = Cost_Tracker_DB::get_instance()->get_all_cost_trackers_by_clients( array( $item->client_id ) );
697 + $client_costs = Cost_Tracker_DB::get_instance()->get_all_cost_trackers_by_clients( array( $item->client_id ), array( 'get_cost_sites' => true ) );
640 698
641 699 if ( is_array( $client_costs ) ) {
642 700 $client_costs = current( $client_costs ); // for current client.
643 701 }
644 702
703 + $allowed = \MainWP\Dashboard\MainWP_Abilities_Util::get_scoped_site_ids_or_all();
704 + if ( 'all' !== $allowed && is_array( $client_costs ) ) {
705 + $client_costs = array_values( array_filter( $client_costs, static fn( $cost ) => ! empty( array_intersect( (array) $cost->cost_sites_ids, $allowed ) ) ) );
706 + }
707 +
645 708 if ( $client_costs ) {
646 - $data = Cost_Tracker_Rest_Api_Handle_V1::instance()->prepare_api_costs_data( $client_costs );
709 + $data = $this->scope_cost_relationship_fields( Cost_Tracker_Rest_Api_Handle_V1::instance()->prepare_api_costs_data( $client_costs ) );
647 710 $resp_data['total'] = is_array( $data ) ? count( $data ) : 0;
648 711 $resp_data['data'] = $data;
649 712 } else {
650 713 $resp_data['message'] = esc_html__( 'Costs not found.', 'mainwp' );
@@ -667,8 +730,9 @@
667 730 $resp_data = array(
668 731 'success' => 1,
669 732 );
670 733 $sites = MainWP_DB_Client::instance()->get_websites_by_client_ids( $item->client_id );
734 + $sites = $this->filter_scoped_sites( $sites );
671 735 $resp_data['total'] = is_array( $sites ) ? count( $sites ) : 0;
672 736 return rest_ensure_response( $resp_data );
673 737 }
674 738
@@ -679,9 +743,9 @@
679 743 *
680 744 * @return WP_Error|WP_REST_Response
681 745 */
682 746 public function suspend_client( $request ) {
683 - $item = $this->get_request_item( $request );
747 + $item = $this->get_request_item( $request, true );
684 748 if ( is_wp_error( $item ) ) {
685 749 return $item;
686 750 }
687 751
@@ -709,9 +773,9 @@
709 773 *
710 774 * @return WP_Error|WP_REST_Response
711 775 */
712 776 public function unsuspend_client( $request ) {
713 - $item = $this->get_request_item( $request );
777 + $item = $this->get_request_item( $request, true );
714 778 if ( is_wp_error( $item ) ) {
715 779 return $item;
716 780 }
717 781
@@ -769,12 +833,14 @@
769 833 }
770 834
771 835 $data = array();
772 836 foreach ( $fields as $field ) {
837 + // A stored "0" is reported as the value it is, not as an empty one. field_id is cast
838 + // because wpdb answers every column as a string and the schema declares an integer.
773 839 $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 : '',
840 + 'field_id' => isset( $field->field_id ) ? (int) $field->field_id : 0,
841 + 'name' => isset( $field->field_name ) ? $field->field_name : '',
842 + 'description' => isset( $field->field_desc ) ? $field->field_desc : '',
777 843 );
778 844 $data[] = $this->filter_response_data_by_allowed_fields( $record, 'field_view' );
779 845 }
780 846
@@ -809,28 +875,57 @@
809 875 * @return WP_Error|WP_REST_Response
810 876 */
811 877 public function create_client_fields( $request ) {
812 878 // Get request body.
813 - $body = $request->get_body_params();
814 - if ( empty( $body ) ) {
879 + $body = $this->get_request_body( $request );
880 + if ( is_wp_error( $body ) ) {
881 + return $body;
882 + }
883 +
884 + $invalid = $this->validate_client_field_values( $body );
885 + if ( is_wp_error( $invalid ) ) {
886 + return $invalid;
887 + }
888 +
889 + // Validate request body. "0" is a value the column stores, so a required value is one whose
890 + // key was sent carrying something other than whitespace, not one that passes a falsy test.
891 + // validate_client_field_values() has already refused a key sent as anything but a string.
892 + foreach ( array( 'name', 'description' ) as $required ) {
893 + if ( ! isset( $body[ $required ] ) || '' === trim( $body[ $required ] ) ) {
894 + return new WP_Error(
895 + 'empty_name',
896 + __( 'Name and description are required.', 'mainwp' ),
897 + array( 'status' => 400 )
898 + );
899 + }
900 + }
901 +
902 + $client_id = 0;
903 + $name = sanitize_text_field( wp_unslash( $body['name'] ) );
904 + $desc = sanitize_text_field( wp_unslash( $body['description'] ) );
905 +
906 + // field_name is varchar(191), so a longer name is truncated by wpdb before the unique index sees
907 + // it and the duplicate lookup, which queries the untruncated name, would not recognise the refusal.
908 + // A body read as raw JSON skips the registered maxLength, so the limit is enforced here as well.
909 + if ( mb_strlen( $name ) > 191 ) {
815 910 return new WP_Error(
816 - 'empty_body',
817 - __( 'Request body is empty.', 'mainwp' ),
911 + 'invalid_field_value',
912 + __( 'Name must be 191 characters or fewer.', 'mainwp' ),
913 + array( 'status' => 400 )
818 914 );
819 915 }
820 916
821 - // Validate request body.
822 - if ( empty( $body['name'] ) || empty( $body['description'] ) ) {
917 + // field_desc is varchar(255), so a longer description is either refused by the write or stored
918 + // truncated depending on the server's strict mode, and neither answers what the caller sent.
919 + // A body read as raw JSON skips the registered maxLength, so the limit is enforced here as well.
920 + if ( mb_strlen( $desc ) > 255 ) {
823 921 return new WP_Error(
824 - 'empty_name',
825 - __( 'Name and description are required.', 'mainwp' ),
922 + 'invalid_field_value',
923 + __( 'Description must be 255 characters or fewer.', 'mainwp' ),
924 + array( 'status' => 400 )
826 925 );
827 926 }
828 927
829 - $client_id = 0;
830 - $name = sanitize_text_field( wp_unslash( $body['name'] ) );
831 - $desc = sanitize_text_field( wp_unslash( $body['description'] ) );
832 -
833 928 $field = MainWP_DB_Client::instance()->add_client_field(
834 929 array(
835 930 'field_name' => $name,
836 931 'field_desc' => $desc,
@@ -838,19 +933,27 @@
838 933 )
839 934 );
840 935
841 936 if ( ! $field ) {
937 + // add_client_field() returns false for a name it refuses to store as well as for a failed
938 + // insert, so a name left empty by sanitizing or already taken stays a client error and
939 + // everything else is a server failure.
940 + $taken = $this->client_field_name_taken( $name, $client_id );
941 + $caller_fault = ( '' === $name || $taken );
942 +
842 943 return new WP_Error(
843 944 'create_field_failed',
844 - __( 'Create client field failed.', 'mainwp' ),
945 + $caller_fault ? __( 'Create client field failed.', 'mainwp' ) : __( 'Creating the client field failed.', 'mainwp' ),
946 + array( 'status' => $caller_fault ? 400 : 500 )
845 947 );
846 948 }
847 949
848 - // Prepare response data.
950 + // Prepare response data. A stored "0" is reported as the value it is, not as an empty one.
951 + // field_id is cast because wpdb answers every column as a string and the schema declares an integer.
849 952 $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 : '',
953 + 'field_id' => isset( $field->field_id ) ? (int) $field->field_id : 0,
954 + 'name' => isset( $field->field_name ) ? $field->field_name : '',
955 + 'description' => isset( $field->field_desc ) ? $field->field_desc : '',
853 956 );
854 957
855 958 return rest_ensure_response(
856 959 array(
@@ -876,35 +979,75 @@
876 979 if ( empty( $field ) ) {
877 980 return new WP_Error(
878 981 'invalid_field_id',
879 982 __( 'Invalid client field.', 'mainwp' ),
983 + array( 'status' => 404 )
880 984 );
881 985 }
882 986
883 987 // Get request body.
884 - $body = $request->get_json_params();
885 - if ( empty( $body ) ) {
988 + $body = $this->get_request_body( $request );
989 + if ( is_wp_error( $body ) ) {
990 + return $body;
991 + }
992 +
993 + $invalid = $this->validate_client_field_values( $body );
994 + if ( is_wp_error( $invalid ) ) {
995 + return $invalid;
996 + }
997 +
998 + // Only a key left out, or sent with nothing but whitespace, keeps the stored value. The add
999 + // route reads a value the same way, and sanitizing trims whitespace away, so a value tested
1000 + // untrimmed here would sanitize down to an empty string the DB layer refuses. "0" is a value
1001 + // the column stores, and a falsy test would drop it and answer that the edit succeeded.
1002 + $name = isset( $body['name'] ) && '' !== trim( $body['name'] ) ? sanitize_text_field( wp_unslash( $body['name'] ) ) : $field->field_name;
1003 + $desc = isset( $body['description'] ) && '' !== trim( $body['description'] ) ? sanitize_text_field( wp_unslash( $body['description'] ) ) : $field->field_desc;
1004 +
1005 + // field_name is varchar(191), so a longer name is truncated by wpdb before the unique index sees
1006 + // it and the duplicate lookup, which queries the untruncated name, would not recognise the refusal.
1007 + // A body read as raw JSON skips the registered maxLength, so the limit is enforced here as well.
1008 + if ( mb_strlen( $name ) > 191 ) {
886 1009 return new WP_Error(
887 - 'empty_body',
888 - __( 'Request body is empty.', 'mainwp' ),
1010 + 'invalid_field_value',
1011 + __( 'Name must be 191 characters or fewer.', 'mainwp' ),
1012 + array( 'status' => 400 )
889 1013 );
890 1014 }
891 1015
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;
1016 + // field_desc is varchar(255), so a longer description is either refused by the write or stored
1017 + // truncated depending on the server's strict mode, and neither answers what the caller sent.
1018 + // A body read as raw JSON skips the registered maxLength, so the limit is enforced here as well.
1019 + if ( mb_strlen( $desc ) > 255 ) {
1020 + return new WP_Error(
1021 + 'invalid_field_value',
1022 + __( 'Description must be 255 characters or fewer.', 'mainwp' ),
1023 + array( 'status' => 400 )
1024 + );
1025 + }
894 1026
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' ),
1027 + // $wpdb->update() reports no changed rows for an edit that stores the values already there, and
1028 + // the DB layer cannot tell that apart from a failure, so an edit that changes nothing is not run.
1029 + if ( $name !== $field->field_name || $desc !== $field->field_desc ) {
1030 + $updated = MainWP_DB_Client::instance()->update_client_field(
1031 + $field->field_id,
1032 + array(
1033 + 'field_name' => $name,
1034 + 'field_desc' => $desc,
1035 + )
906 1036 );
1037 + if ( ! $updated ) {
1038 + // The DB layer refuses a name left empty by sanitizing, and the unique index on
1039 + // (client_id, field_name) refuses a rename onto a name another field of this client
1040 + // already owns. Both are the caller's fault; anything else is the write itself failing.
1041 + $owned = $this->client_field_name_taken( $name, $field->client_id, $field->field_id );
1042 + $caller_fault = ( '' === $name || $owned );
1043 +
1044 + return new WP_Error(
1045 + 'update_field_failed',
1046 + $caller_fault ? __( 'Field already exists, try different field name.', 'mainwp' ) : __( 'Updating the client field failed.', 'mainwp' ),
1047 + array( 'status' => $caller_fault ? 400 : 500 )
1048 + );
1049 + }
907 1050 }
908 1051
909 1052 return rest_ensure_response(
910 1053 array(
@@ -958,19 +1101,26 @@
958 1101 *
959 1102 * @return array
960 1103 */
961 1104 public function edit_client_fields_allowed_fields() {
1105 + // WordPress only runs the type check its own sanitizer carries, and a registered
1106 + // sanitize_callback replaces it, so without an explicit validate_callback a JSON 42 or true
1107 + // would reach the handler already cast to text by sanitize_text_field().
962 1108 return array(
963 1109 'name' => array(
964 1110 'required' => false,
965 1111 'type' => 'string',
1112 + 'maxLength' => 191,
966 1113 'sanitize_callback' => 'sanitize_text_field',
1114 + 'validate_callback' => 'rest_validate_request_arg',
967 1115 'description' => __( 'Field name.', 'mainwp' ),
968 1116 ),
969 1117 'description' => array(
970 1118 'required' => false,
971 1119 'type' => 'string',
1120 + 'maxLength' => 255,
972 1121 'sanitize_callback' => 'sanitize_text_field',
1122 + 'validate_callback' => 'rest_validate_request_arg',
973 1123 'description' => __( 'Field description.', 'mainwp' ),
974 1124 ),
975 1125 );
976 1126 }
@@ -977,22 +1127,28 @@
977 1127
978 1128 /**
979 1129 * Create client fields allowed fields.
980 1130 *
981 - * @return WP_Error|WP_REST_Response
1131 + * @return array
982 1132 */
983 1133 public function create_client_fields_allowed_fields() {
1134 + // See edit_client_fields_allowed_fields(): the registered sanitizer displaces the type check
1135 + // WordPress would otherwise run, so the declared type needs its own validate_callback.
984 1136 return array(
985 1137 'name' => array(
986 1138 'required' => true,
987 1139 'type' => 'string',
1140 + 'maxLength' => 191,
988 1141 'sanitize_callback' => 'sanitize_text_field',
1142 + 'validate_callback' => 'rest_validate_request_arg',
989 1143 'description' => __( 'Field name.', 'mainwp' ),
990 1144 ),
991 1145 'description' => array(
992 1146 'required' => true,
993 1147 'type' => 'string',
1148 + 'maxLength' => 255,
994 1149 'sanitize_callback' => 'sanitize_text_field',
1150 + 'validate_callback' => 'rest_validate_request_arg',
995 1151 'description' => __( 'Field description.', 'mainwp' ),
996 1152 ),
997 1153 );
998 1154 }
@@ -1050,8 +1206,109 @@
1050 1206 );
1051 1207 }
1052 1208
1053 1209 /**
1210 + * Get request body.
1211 + *
1212 + * @param WP_REST_Request $request Full details about the request.
1213 + *
1214 + * @return array|WP_Error Body payload, or an error when the request carries none.
1215 + */
1216 + private function get_request_body( $request ) {
1217 + // Get request body from form-encoded senders.
1218 + $body = $request->get_body_params();
1219 + if ( ! empty( $body ) && is_array( $body ) ) {
1220 + return $body;
1221 + }
1222 +
1223 + // Get request body from JSON senders.
1224 + $body = $request->get_json_params();
1225 + if ( ! empty( $body ) && is_array( $body ) ) {
1226 + return $body;
1227 + }
1228 +
1229 + // Get request body from raw, for JSON sent without the JSON content type.
1230 + $body = $request->get_body();
1231 + if ( ! empty( $body ) && is_string( $body ) ) {
1232 + $body = json_decode( $body, true );
1233 + if ( is_array( $body ) && ! empty( $body ) ) {
1234 + return $body;
1235 + }
1236 + }
1237 +
1238 + return new WP_Error(
1239 + 'empty_body',
1240 + __( 'Request body is empty.', 'mainwp' ),
1241 + array( 'status' => 400 )
1242 + );
1243 + }
1244 +
1245 + /**
1246 + * Validate the client field text values carried by a request body.
1247 + *
1248 + * @param array $body Request body.
1249 + *
1250 + * @return WP_Error|true Error when a value cannot be stored as text.
1251 + */
1252 + private function validate_client_field_values( $body ) {
1253 + foreach ( array( 'name', 'description' ) as $key ) {
1254 + // A body read as raw JSON skips the registered sanitizers, so a value that is not already
1255 + // text reaches sanitize_text_field() and is stored as a cast of itself or as an empty
1256 + // string, wiping what it meant to set. A key sent as null counts as sent, not as omitted.
1257 + if ( array_key_exists( $key, $body ) && ! is_string( $body[ $key ] ) ) {
1258 + return new WP_Error(
1259 + 'invalid_field_value',
1260 + __( 'Name and description must be text values.', 'mainwp' ),
1261 + array( 'status' => 400 )
1262 + );
1263 + }
1264 + }
1265 +
1266 + return true;
1267 + }
1268 +
1269 + /**
1270 + * Whether a client already has a field stored under the given name.
1271 + *
1272 + * @param string $name Field name as the handler sanitized it before the write.
1273 + * @param int $client_id Client the field belongs to.
1274 + * @param int $exclude_id Field id to skip, for a rename that keeps its own name.
1275 + *
1276 + * @uses MainWP_DB_Client::instance()->get_client_fields_by_params()
1277 + *
1278 + * @return bool
1279 + */
1280 + private function client_field_name_taken( $name, $client_id, $exclude_id = 0 ) {
1281 + // A field_name lookup strips [ and ] from the value it queries with while the stored name keeps
1282 + // them, and a PHP comparison would call two names that differ only in case distinct while the
1283 + // column collation and the unique index call them the same. The match is left to the database
1284 + // on the stored value so this answer agrees with the index that refused the write.
1285 + $client_id = (int) $client_id;
1286 + $fields = MainWP_DB_Client::instance()->get_client_fields_by_params(
1287 + array(
1288 + 'client_id' => $client_id,
1289 + 'field_name_exact' => (string) $name,
1290 + )
1291 + );
1292 +
1293 + if ( ! is_array( $fields ) ) {
1294 + return false;
1295 + }
1296 +
1297 + foreach ( $fields as $existing ) {
1298 + // Client 0 holds the general fields this route writes, and 0 is not a value the query filters
1299 + // on, so the owner check happens here.
1300 + if ( (int) $existing->client_id !== $client_id || (int) $existing->field_id === (int) $exclude_id ) {
1301 + continue;
1302 + }
1303 +
1304 + return true;
1305 + }
1306 +
1307 + return false;
1308 + }
1309 +
1310 + /**
1054 1311 * Get client field request id or name
1055 1312 *
1056 1313 * @param WP_REST_Request $request Full details about the request.
1057 1314 *
@@ -1063,13 +1320,14 @@
1063 1320 $raw = $request->get_param( 'id_name' );
1064 1321 $decoded = rawurldecode( (string) $raw );
1065 1322 $field_value = trim( sanitize_text_field( wp_unslash( $decoded ) ) );
1066 1323
1067 - if ( empty( $field_value ) ) {
1324 + if ( '' === $field_value ) {
1068 1325 return false;
1069 1326 }
1070 1327
1071 - // Get client field by id.
1328 + // Get client field by id. A digit-only segment is an id, so "0" and any other all-digit name is
1329 + // addressable by its own id only, never by name.
1072 1330 if ( ctype_digit( $field_value ) ) {
1073 1331 return MainWP_DB_Client::instance()->get_client_fields_by( 'field_id', (int) $field_value );
1074 1332 }
1075 1333