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-clients-controller.php +665 -29 5.2.1 → 6.2.1 View file →
@@ -10,9 +10,15 @@
10 10 use MainWP\Dashboard\MainWP_DB_Client;
11 11 use MainWP\Dashboard\MainWP_Client_Handler;
12 12 use MainWP\Dashboard\Module\CostTracker\Cost_Tracker_DB;
13 13 use MainWP\Dashboard\Module\CostTracker\Cost_Tracker_Rest_Api_Handle_V1;
14 +use MainWP\Dashboard\MainWP_Utility;
14 15
16 +// Exit if accessed directly.
17 +if ( ! defined( 'ABSPATH' ) ) {
18 + exit;
19 +}
20 +
15 21 /**
16 22 * Class MainWP_Rest_Clients_Controller
17 23 *
18 24 * @package MainWP\Dashboard
@@ -42,9 +48,8 @@
42 48 * @var string
43 49 */
44 50 protected $rest_base = 'clients';
45 51
46 -
47 52 /**
48 53 * Method instance()
49 54 *
50 55 * Create public static instance.
@@ -58,13 +63,14 @@
58 63 }
59 64 return static::$instance;
60 65 }
61 66
62 - /**
63 - * Constructor.
64 - */
67 + /**
68 + * Constructor.
69 + */
65 70 public function __construct() {
66 71 add_filter( 'mainwp_rest_routes_clients_controller_filter_allowed_fields_by_context', array( $this, 'hook_filter_allowed_fields_by_context' ), 10, 2 );
72 + add_filter( 'mainwp_rest_client_fields_object_query', array( $this, 'client_fields_custom_query_args' ), 10, 2 );
67 73 }
68 74
69 75 /**
70 76 * Get filter client.
@@ -78,8 +84,24 @@
78 84 return $this->filter_response_data_by_allowed_fields( $item, $context );
79 85 }
80 86
81 87 /**
88 + * Add custom query args.
89 + *
90 + * @param array $args Query args.
91 + * @param WP_REST_Request $request Request object.
92 + *
93 + * @return array
94 + */
95 + public function client_fields_custom_query_args( $args, $request ) {
96 + if ( ! empty( $request['client_id'] ) ) {
97 + $args['client_id'] = $request['client_id'];
98 + }
99 +
100 + return $args;
101 + }
102 +
103 + /**
82 104 * Method register_routes()
83 105 *
84 106 * Creates the necessary endpoints for the api.
85 107 * Note, for a request to be successful the URL query parameters consumer_key and consumer_secret need to be set and correct.
@@ -120,8 +142,9 @@
120 142 array(
121 143 'methods' => WP_REST_Server::CREATABLE,
122 144 'callback' => array( $this, 'create_item' ),
123 145 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
146 + 'args' => $this->get_collection_params(),
124 147 ),
125 148 )
126 149 );
127 150
@@ -127,9 +150,9 @@
127 150
128 151 // Retrieves client by ID or Email.
129 152 register_rest_route(
130 153 $this->namespace,
131 - '/' . $this->rest_base . '/(?P<id_email>[\d]+)',
154 + '/' . $this->rest_base . '/(?P<id_email>(?!fields$)[\d]+)',
132 155 array(
133 156 array(
134 157 'methods' => WP_REST_Server::READABLE,
135 158 'callback' => array( $this, 'get_item' ),
@@ -140,9 +163,9 @@
140 163
141 164 // Retrieves client by ID or Email.
142 165 register_rest_route(
143 166 $this->namespace,
144 - '/' . $this->rest_base . '/(?P<id_email>[a-zA-Z0-9\.\_\%\+\-\@]+)',
167 + '/' . $this->rest_base . '/(?P<id_email>(?!fields$)[a-zA-Z0-9\.\_\%\+\-\@]+)',
145 168 array(
146 169 array(
147 170 'methods' => WP_REST_Server::READABLE,
148 171 'callback' => array( $this, 'get_item' ),
@@ -153,9 +176,9 @@
153 176
154 177 // Edit client by ID or Email.
155 178 register_rest_route(
156 179 $this->namespace,
157 - '/' . $this->rest_base . '/(?P<id_email>[a-zA-Z0-9\_\-\.\@]+)/edit',
180 + '/' . $this->rest_base . '/(?P<id_email>(?!fields$)[a-zA-Z0-9\_\-\.\@]+)/edit',
158 181 array(
159 182 array(
160 183 'methods' => WP_REST_Server::EDITABLE,
161 184 'callback' => array( $this, 'update_item' ),
@@ -166,9 +189,9 @@
166 189
167 190 // Removes client by ID or Email from the Dashboard.
168 191 register_rest_route(
169 192 $this->namespace,
170 - '/' . $this->rest_base . '/(?P<id_email>[a-zA-Z0-9\_\-\.\@]+)/remove',
193 + '/' . $this->rest_base . '/(?P<id_email>(?!fields$)[a-zA-Z0-9\_\-\.\@]+)/remove',
171 194 array(
172 195 array(
173 196 'methods' => WP_REST_Server::DELETABLE,
174 197 'callback' => array( $this, 'delete_item' ),
@@ -179,9 +202,9 @@
179 202
180 203 // Retrieves all sites for a client by client ID or Email.
181 204 register_rest_route(
182 205 $this->namespace,
183 - '/' . $this->rest_base . '/(?P<id_email>[a-zA-Z0-9\_\-\.\@]+)/sites',
206 + '/' . $this->rest_base . '/(?P<id_email>(?!fields$)[a-zA-Z0-9\_\-\.\@]+)/sites',
184 207 array(
185 208 array(
186 209 'methods' => WP_REST_Server::READABLE,
187 210 'callback' => array( $this, 'get_sites_client' ),
@@ -192,9 +215,9 @@
192 215
193 216 // Retrieves all costs for a client by client ID or Email.
194 217 register_rest_route(
195 218 $this->namespace,
196 - '/' . $this->rest_base . '/(?P<id_email>[a-zA-Z0-9\_\-\.\@]+)/costs',
219 + '/' . $this->rest_base . '/(?P<id_email>(?!fields$)[a-zA-Z0-9\_\-\.\@]+)/costs',
197 220 array(
198 221 array(
199 222 'methods' => WP_REST_Server::READABLE,
200 223 'callback' => array( $this, 'get_costs_client' ),
@@ -205,9 +228,9 @@
205 228
206 229 // Retrieves the number of sites for a client by client ID or Email.
207 230 register_rest_route(
208 231 $this->namespace,
209 - '/' . $this->rest_base . '/(?P<id_email>[a-zA-Z0-9\_\-\.\@]+)/sites/count',
232 + '/' . $this->rest_base . '/(?P<id_email>(?!fields$)[a-zA-Z0-9\_\-\.\@]+)/sites/count',
210 233 array(
211 234 array(
212 235 'methods' => WP_REST_Server::READABLE,
213 236 'callback' => array( $this, 'count_sites_client' ),
@@ -218,9 +241,9 @@
218 241
219 242 // Suspends client by ID or Domain.
220 243 register_rest_route(
221 244 $this->namespace,
222 - '/' . $this->rest_base . '/(?P<id_email>[a-zA-Z0-9\_\-\.\@]+)/suspend',
245 + '/' . $this->rest_base . '/(?P<id_email>(?!fields$)[a-zA-Z0-9\_\-\.\@]+)/suspend',
223 246 array(
224 247 array(
225 248 'methods' => WP_REST_Server::EDITABLE,
226 249 'callback' => array( $this, 'suspend_client' ),
@@ -231,9 +254,9 @@
231 254
232 255 // Unsuspends client by ID or Domain.
233 256 register_rest_route(
234 257 $this->namespace,
235 - '/' . $this->rest_base . '/(?P<id_email>[a-zA-Z0-9\_\-\.\@]+)/unsuspend',
258 + '/' . $this->rest_base . '/(?P<id_email>(?!fields$)[a-zA-Z0-9\_\-\.\@]+)/unsuspend',
236 259 array(
237 260 array(
238 261 'methods' => WP_REST_Server::EDITABLE,
239 262 'callback' => array( $this, 'unsuspend_client' ),
@@ -253,8 +276,59 @@
253 276 ),
254 277 'schema' => array( $this, 'get_public_batch_schema' ),
255 278 )
256 279 );
280 +
281 + // Client Fields.
282 + register_rest_route(
283 + $this->namespace,
284 + '/' . $this->rest_base . '/fields',
285 + array(
286 + array(
287 + 'methods' => WP_REST_Server::READABLE,
288 + 'callback' => array( $this, 'client_fields' ),
289 + 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
290 + 'args' => $this->get_client_fields_allowed_fields(),
291 + ),
292 + )
293 + );
294 +
295 + register_rest_route(
296 + $this->namespace,
297 + '/' . $this->rest_base . '/fields/add',
298 + array(
299 + array(
300 + 'methods' => WP_REST_Server::CREATABLE,
301 + 'callback' => array( $this, 'create_client_fields' ),
302 + 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
303 + 'args' => $this->create_client_fields_allowed_fields(),
304 + ),
305 + )
306 + );
307 +
308 + register_rest_route(
309 + $this->namespace,
310 + '/' . $this->rest_base . '/fields/(?P<id_name>[^/]+)/edit',
311 + array(
312 + array(
313 + 'methods' => 'PUT, PATCH',
314 + 'callback' => array( $this, 'edit_client_fields' ),
315 + 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
316 + 'args' => $this->edit_client_fields_allowed_fields(),
317 + ),
318 + )
319 + );
320 + register_rest_route(
321 + $this->namespace,
322 + '/' . $this->rest_base . '/fields/(?P<id_name>[^/]+)/delete',
323 + array(
324 + array(
325 + 'methods' => WP_REST_Server::DELETABLE,
326 + 'callback' => array( $this, 'delete_client_fields' ),
327 + 'permission_callback' => array( $this, 'get_rest_permissions_check' ),
328 + ),
329 + )
330 + );
257 331 }
258 332
259 333 /**
260 334 * Get site by.
@@ -260,13 +334,14 @@
260 334 * Get site by.
261 335 *
262 336 * @param WP_REST_Request $request Full details about the request.
263 337 *
264 - * @return WP_Error|Object Item.
338 + * @return WP_Error|bool|Object Item.
265 339 */
266 340 public function get_request_item( $request ) {
267 341 $route = $request->get_route();
268 - if ( str_ends_with( $route, '/batch' ) ) {
342 +
343 + if ( MainWP_Utility::string_ends_by( $route, '/batch' ) ) {
269 344 $by = 'id';
270 345 $value = intval( $request['id'] );
271 346 } else {
272 347 $value = $request['id_email'];
@@ -280,8 +355,19 @@
280 355 return $this->get_client_by( $by, $value );
281 356 }
282 357
283 358 /**
359 + * Get the query params for collections.
360 + *
361 + * @return array
362 + */
363 + public function get_collection_params() {
364 + $params = array();
365 + $params['context']['default'] = 'view';
366 + return $params;
367 + }
368 +
369 + /**
284 370 * Get formatted item object.
285 371 *
286 372 * @since 5.2
287 373 * @param array $data data object.
@@ -291,9 +377,9 @@
291 377 protected function get_formatted_item_data( $data ) {
292 378 if ( ! empty( $data['created'] ) ) {
293 379 $data['created'] = mainwp_rest_prepare_date_response( $data['created'] );
294 380 }
295 - if ( isset( $data['client_id'] ) ) {
381 + if ( isset( $data['client_id'] ) && ! isset( $data['field_id'] ) ) {
296 382 $data['id'] = $data['client_id'];
297 383 unset( $data['client_id'] );
298 384 }
299 385 return $data;
@@ -411,15 +497,18 @@
411 497 public function create_item( $request ) {
412 498 $resp_data = array();
413 499 $resp_data['success'] = 0;
414 500 try {
415 - $item = array_filter( $request->get_params() );
501 + $item = array_filter( $request->get_params() );
502 + if ( isset( $item['selected_sites'] ) ) {
503 + $item['selected_sites'] = array_unique( wp_parse_list( $item['selected_sites'] ) );
504 + }
416 505 $result = MainWP_Client_Handler::rest_api_add_client( $item );
417 - if ( is_array( $result ) && isset( $result['clientid'] ) ) {
418 - $client = MainWP_DB_Client::instance()->get_wp_client_by( 'client_id', $result['clientid'] );
506 + if ( is_array( $result ) && isset( $result['client_id'] ) ) {
507 + $client = MainWP_DB_Client::instance()->get_wp_client_by( 'client_id', $result['client_id'], OBJECT, array( 'with_selected_sites' => true ) );
419 508 $resp_data['success'] = 1;
420 509 $resp_data['message'] = esc_html__( 'Client created Successfully.', 'mainwp' );
421 - $resp_data['data'] = $this->filter_response_data_by_allowed_fields( $client, 'simple_view' );
510 + $resp_data['data'] = $this->filter_response_data_by_allowed_fields( $client, 'simple_view', array( 'selected_sites' ) );
422 511 } elseif ( is_array( $result ) && ! empty( $result['error'] ) ) {
423 512 $resp_data['error'] = wp_strip_all_tags( $result['error'] );
424 513 } else {
425 514 $resp_data['error'] = esc_html__( 'Add client failed. Please try again.', 'mainwp' );
@@ -451,14 +540,17 @@
451 540 $data = array_filter( $request->get_params() );
452 541 if ( is_array( $data ) ) {
453 542 $data['client_id'] = $item->client_id;
454 543 }
544 + if ( isset( $data['selected_sites'] ) ) {
545 + $data['selected_sites'] = array_unique( wp_parse_list( $data['selected_sites'] ) );
546 + }
455 547 $result = MainWP_Client_Handler::rest_api_add_client( $data, true );
456 - if ( is_array( $result ) && isset( $result['clientid'] ) ) {
457 - $client = MainWP_DB_Client::instance()->get_wp_client_by( 'client_id', $result['clientid'] );
548 + if ( is_array( $result ) && isset( $result['client_id'] ) ) {
549 + $client = MainWP_DB_Client::instance()->get_wp_client_by( 'client_id', $result['client_id'], OBJECT, array( 'with_selected_sites' => true ) );
458 550 $resp_data['success'] = 1;
459 551 $resp_data['message'] = esc_html__( 'Client updated Successfully.', 'mainwp' );
460 - $resp_data['data'] = $this->filter_response_data_by_allowed_fields( $client, 'simple_view' );
552 + $resp_data['data'] = $this->filter_response_data_by_allowed_fields( $client, 'simple_view', array( 'selected_sites' ) );
461 553 } elseif ( is_array( $result ) && ! empty( $result['error'] ) ) {
462 554 $resp_data['error'] = wp_strip_all_tags( $result['error'] );
463 555 } else {
464 556 $resp_data['error'] = esc_html__( 'Update client failed. Please try again.', 'mainwp' );
@@ -490,10 +582,8 @@
490 582 );
491 583 return rest_ensure_response( $resp_data );
492 584 }
493 585
494 -
495 -
496 586 /**
497 587 * Get sites of client.
498 588 *
499 589 * @param WP_REST_Request $request Full details about the request.
@@ -581,9 +671,8 @@
581 671 $resp_data['total'] = is_array( $sites ) ? count( $sites ) : 0;
582 672 return rest_ensure_response( $resp_data );
583 673 }
584 674
585 -
586 675 /**
587 676 * Suspend client.
588 677 *
589 678 * @param WP_REST_Request $request Full details about the request.
@@ -606,9 +695,9 @@
606 695 $client = MainWP_DB_Client::instance()->get_wp_client_by( 'client_id', $item->client_id );
607 696
608 697 $resp_data = array(
609 698 'success' => 1,
610 - 'message' => esc_html__( 'Client suspended successfully.' ),
699 + 'message' => esc_html__( 'Client suspended successfully.', 'mainwp' ),
611 700 'data' => $this->filter_response_data_by_allowed_fields( $client, 'simple_view' ),
612 701 );
613 702 return rest_ensure_response( $resp_data );
614 703 }
@@ -636,16 +725,553 @@
636 725 $client = MainWP_DB_Client::instance()->get_wp_client_by( 'client_id', $item->client_id );
637 726
638 727 $resp_data = array(
639 728 'success' => 1,
640 - 'message' => esc_html__( 'Client unsuspended successfully.' ),
729 + 'message' => esc_html__( 'Client unsuspended successfully.', 'mainwp' ),
641 730 'data' => $this->filter_response_data_by_allowed_fields( $client, 'simple_view' ),
642 731 );
643 732 return rest_ensure_response( $resp_data );
644 733 }
645 734
735 + /**
736 + * Get client fields.
737 + *
738 + * @param WP_REST_Request $request Full details about the request.
739 + *
740 + * @uses MainWP_DB_Client::instance()->get_client_fields_by_params()
741 + *
742 + * @return WP_Error|WP_REST_Response
743 + */
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 + }
646 749
750 + // Prepare query args.
751 + $args = $this->prepare_objects_query( $request, 'client_fields' );
752 + $page = ! empty( $args['paged'] ) ? (int) $args['paged'] : 1;
753 + $per_page = ! empty( $args['items_per_page'] ) ? (int) $args['items_per_page'] : 20;
754 + $params = array(
755 + 'exclude' => ! empty( $args['exclude'] ) ? $args['exclude'] : '',
756 + 'include' => ! empty( $args['include'] ) ? $args['include'] : '',
757 + 'search' => ! empty( $args['s'] ) ? $args['s'] : '',
758 + );
759 +
760 + // Get client fields.
761 + $fields = MainWP_DB_Client::instance()->get_client_fields_by_params( $params );
762 + if ( empty( $fields ) ) {
763 + return rest_ensure_response(
764 + array(
765 + 'success' => 0,
766 + 'message' => __( 'No client fields found.', 'mainwp' ),
767 + )
768 + );
769 + }
770 +
771 + $data = array();
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.
775 + $record = array(
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 : '',
779 + );
780 + $data[] = $this->filter_response_data_by_allowed_fields( $record, 'field_view' );
781 + }
782 +
783 + // Page navigation.
784 + $total = count( $data );
785 + $pages = (int) max( 1, ceil( $total / max( 1, $per_page ) ) );
786 + if ( $page > $pages ) {
787 + $page = $pages;
788 + }
789 +
790 + $offset = ( $page - 1 ) * $per_page;
791 + $data = array_slice( $data, $offset, $per_page );
792 +
793 + return rest_ensure_response(
794 + array(
795 + 'success' => 1,
796 + 'total' => $total,
797 + 'pages' => $pages,
798 + 'data' => $data,
799 + )
800 + );
801 + }
802 +
647 803 /**
804 + * Create client fields.
805 + *
806 + * @param WP_REST_Request $request Full details about the request.
807 + *
808 + * @uses MainWP_DB_Client::instance()->add_client_field()
809 + * @uses MainWP_DB_Client::instance()->get_wp_client_by()
810 + *
811 + * @return WP_Error|WP_REST_Response
812 + */
813 + public function create_client_fields( $request ) {
814 + // Get request 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 ) {
846 + return new WP_Error(
847 + 'invalid_field_value',
848 + __( 'Name must be 191 characters or fewer.', 'mainwp' ),
849 + array( 'status' => 400 )
850 + );
851 + }
852 +
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 ) {
857 + return new WP_Error(
858 + 'invalid_field_value',
859 + __( 'Description must be 255 characters or fewer.', 'mainwp' ),
860 + array( 'status' => 400 )
861 + );
862 + }
863 +
864 + $field = MainWP_DB_Client::instance()->add_client_field(
865 + array(
866 + 'field_name' => $name,
867 + 'field_desc' => $desc,
868 + 'client_id' => $client_id,
869 + )
870 + );
871 +
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 +
879 + return new WP_Error(
880 + 'create_field_failed',
881 + $caller_fault ? __( 'Create client field failed.', 'mainwp' ) : __( 'Creating the client field failed.', 'mainwp' ),
882 + array( 'status' => $caller_fault ? 400 : 500 )
883 + );
884 + }
885 +
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.
888 + $data = array(
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 : '',
892 + );
893 +
894 + return rest_ensure_response(
895 + array(
896 + 'success' => 1,
897 + 'message' => __( 'Client field created successfully.', 'mainwp' ),
898 + 'data' => $this->filter_response_data_by_allowed_fields( $data, 'field_edit' ),
899 + )
900 + );
901 + }
902 +
903 + /**
904 + * Edit client fields.
905 + *
906 + * @param WP_REST_Request $request Full details about the request.
907 + *
908 + * @users MainWP_DB_Client::instance()->update_client_field()
909 + *
910 + * @return WP_Error|WP_REST_Response
911 + */
912 + public function edit_client_fields( $request ) {
913 + // Get client field.
914 + $field = $this->get_request_client_fields_item( $request );
915 + if ( empty( $field ) ) {
916 + return new WP_Error(
917 + 'invalid_field_id',
918 + __( 'Invalid client field.', 'mainwp' ),
919 + array( 'status' => 404 )
920 + );
921 + }
922 +
923 + // Get request 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 ) {
945 + return new WP_Error(
946 + 'invalid_field_value',
947 + __( 'Name must be 191 characters or fewer.', 'mainwp' ),
948 + array( 'status' => 400 )
949 + );
950 + }
951 +
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 + }
962 +
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 + )
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 + }
986 + }
987 +
988 + return rest_ensure_response(
989 + array(
990 + 'success' => 1,
991 + 'message' => __( 'Client field updated successfully.', 'mainwp' ),
992 + )
993 + );
994 + }
995 +
996 + /**
997 + * Delete client fields.
998 + *
999 + * @param WP_REST_Request $request Full details about the request.
1000 + *
1001 + * @users MainWP_DB_Client::instance()->delete_client_field_by()
1002 + *
1003 + * @return WP_Error|WP_REST_Response
1004 + */
1005 + public function delete_client_fields( $request ) {
1006 + // Get client field.
1007 + $field = $this->get_request_client_fields_item( $request );
1008 + if ( empty( $field ) ) {
1009 + return new WP_Error(
1010 + 'invalid_field_id',
1011 + __( 'Invalid client field.', 'mainwp' ),
1012 + );
1013 + }
1014 +
1015 + // Delete client field.
1016 + $field_id = ! empty( $field->field_id ) ? $field->field_id : 0;
1017 + $client_id = ! empty( $field->client_id ) ? $field->client_id : 0;
1018 + $deleted = MainWP_DB_Client::instance()->delete_client_field_by( 'field_id', $field_id, $client_id );
1019 +
1020 + if ( ! $deleted ) {
1021 + return new WP_Error(
1022 + 'delete_field_failed',
1023 + __( 'Delete client field failed.', 'mainwp' ),
1024 + );
1025 + }
1026 +
1027 + return rest_ensure_response(
1028 + array(
1029 + 'success' => 1,
1030 + 'message' => esc_html__( 'Client field deleted successfully.', 'mainwp' ),
1031 + )
1032 + );
1033 + }
1034 +
1035 + /**
1036 + * Edit client fields allowed fields.
1037 + *
1038 + * @return array
1039 + */
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().
1044 + return array(
1045 + 'name' => array(
1046 + 'required' => false,
1047 + 'type' => 'string',
1048 + 'maxLength' => 191,
1049 + 'sanitize_callback' => 'sanitize_text_field',
1050 + 'validate_callback' => 'rest_validate_request_arg',
1051 + 'description' => __( 'Field name.', 'mainwp' ),
1052 + ),
1053 + 'description' => array(
1054 + 'required' => false,
1055 + 'type' => 'string',
1056 + 'maxLength' => 255,
1057 + 'sanitize_callback' => 'sanitize_text_field',
1058 + 'validate_callback' => 'rest_validate_request_arg',
1059 + 'description' => __( 'Field description.', 'mainwp' ),
1060 + ),
1061 + );
1062 + }
1063 +
1064 + /**
1065 + * Create client fields allowed fields.
1066 + *
1067 + * @return array
1068 + */
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.
1072 + return array(
1073 + 'name' => array(
1074 + 'required' => true,
1075 + 'type' => 'string',
1076 + 'maxLength' => 191,
1077 + 'sanitize_callback' => 'sanitize_text_field',
1078 + 'validate_callback' => 'rest_validate_request_arg',
1079 + 'description' => __( 'Field name.', 'mainwp' ),
1080 + ),
1081 + 'description' => array(
1082 + 'required' => true,
1083 + 'type' => 'string',
1084 + 'maxLength' => 255,
1085 + 'sanitize_callback' => 'sanitize_text_field',
1086 + 'validate_callback' => 'rest_validate_request_arg',
1087 + 'description' => __( 'Field description.', 'mainwp' ),
1088 + ),
1089 + );
1090 + }
1091 +
1092 + /**
1093 + * Get client fields allowed fields.
1094 + *
1095 + * @return array
1096 + */
1097 + public function get_client_fields_allowed_fields() {
1098 + return array(
1099 + 'search' => array(
1100 + 'required' => false,
1101 + 'type' => 'string',
1102 + 'sanitize_callback' => 'sanitize_text_field',
1103 + 'description' => __( 'Search client field name.', 'mainwp' ),
1104 + ),
1105 + 'include' => array(
1106 + 'required' => false,
1107 + 'type' => 'string',
1108 + 'sanitize_callback' => 'sanitize_text_field',
1109 + 'description' => __( 'Include client field IDs.', 'mainwp' ),
1110 + ),
1111 + 'exclude' => array(
1112 + 'required' => false,
1113 + 'type' => 'string',
1114 + 'sanitize_callback' => 'sanitize_text_field',
1115 + 'description' => __( 'Exclude client field IDs.', 'mainwp' ),
1116 + ),
1117 + 'page' => array(
1118 + 'required' => false,
1119 + 'type' => 'integer',
1120 + 'minimum' => 1,
1121 + 'sanitize_callback' => 'absint',
1122 + 'description' => __( 'Page number.', 'mainwp' ),
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 + ),
1133 + 'pre_page' => array(
1134 + 'required' => false,
1135 + 'type' => 'integer',
1136 + 'sanitize_callback' => 'absint',
1137 + 'validate_callback' => 'rest_validate_request_arg',
1138 + 'minimum' => 1,
1139 + 'maximum' => 200,
1140 + 'description' => __( 'Deprecated alias of per_page. Number of client fields per page.', 'mainwp' ),
1141 + ),
1142 + );
1143 + }
1144 +
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 + /**
1247 + * Get client field request id or name
1248 + *
1249 + * @param WP_REST_Request $request Full details about the request.
1250 + *
1251 + * @uses MainWP_DB_Client::instance()->get_client_fields_by()
1252 + *
1253 + * @return Object|bool Item.
1254 + */
1255 + private function get_request_client_fields_item( $request ) {
1256 + $raw = $request->get_param( 'id_name' );
1257 + $decoded = rawurldecode( (string) $raw );
1258 + $field_value = trim( sanitize_text_field( wp_unslash( $decoded ) ) );
1259 +
1260 + if ( '' === $field_value ) {
1261 + return false;
1262 + }
1263 +
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.
1266 + if ( ctype_digit( $field_value ) ) {
1267 + return MainWP_DB_Client::instance()->get_client_fields_by( 'field_id', (int) $field_value );
1268 + }
1269 +
1270 + return MainWP_DB_Client::instance()->get_client_fields_by( 'field_name', $field_value );
1271 + }
1272 +
1273 + /**
648 1274 * Get the Tags schema, conforming to JSON Schema.
649 1275 *
650 1276 * @since 5.2
651 1277 * @return array
@@ -668,9 +1294,9 @@
668 1294 ),
669 1295 'name' => array(
670 1296 'type' => 'string',
671 1297 'description' => __( 'Client name.', 'mainwp' ),
672 - 'context' => array( 'view', 'edit', 'simple_view' ),
1298 + 'context' => array( 'view', 'edit', 'simple_view', 'field_view', 'field_edit' ),
673 1299 ),
674 1300 'address_1' => array(
675 1301 'type' => 'string',
676 1302 'description' => __( 'Address 1.', 'mainwp' ),
@@ -749,8 +1375,18 @@
749 1375 'primary_contact_id' => array(
750 1376 'type' => 'integer',
751 1377 'description' => __( 'Primary contact id.', 'mainwp' ),
752 1378 'context' => array( 'view', 'edit' ),
1379 + ),
1380 + 'field_id' => array(
1381 + 'type' => 'integer',
1382 + 'description' => __( 'Field ID.', 'mainwp' ),
1383 + 'context' => array( 'field_view', 'field_edit' ),
1384 + ),
1385 + 'description' => array(
1386 + 'type' => 'string',
1387 + 'description' => __( 'Field description.', 'mainwp' ),
1388 + 'context' => array( 'field_view', 'field_edit' ),
753 1389 ),
754 1390 ),
755 1391 );
756 1392 }