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-global-batch-controller.php +267 -170 6.1.8 → 6.2.1 View file →
@@ -44,11 +44,39 @@
44 44 * Controller names handled by the batch endpoint.
45 45 *
46 46 * @var array
47 47 */
48 - protected $controller_names = array( 'sites', 'clients', 'costs', 'tags' );
48 + protected $controller_names = array( 'sites', 'clients', 'tags' );
49 49
50 50 /**
51 + * Actions the dispatch below reads per group, each marked with the shape it reads its items in:
52 + * an 'item' action passes every item on as a request body, an 'id' action casts every item to an int.
53 + *
54 + * @var array
55 + */
56 + const GROUP_ACTIONS = array(
57 + 'sites' => array(
58 + 'create' => 'item',
59 + 'sync' => 'id',
60 + 'reconnect' => 'id',
61 + 'disconnect' => 'id',
62 + 'suspend' => 'id',
63 + 'check' => 'id',
64 + 'remove' => 'id',
65 + 'security' => 'id',
66 + 'plugins' => 'id',
67 + 'themes' => 'id',
68 + 'non-mainwp-changes' => 'id',
69 + ),
70 + 'clients' => array(
71 + 'create' => 'item',
72 + ),
73 + 'tags' => array(
74 + 'create' => 'item',
75 + ),
76 + );
77 +
78 + /**
51 79 * Method instance()
52 80 *
53 81 * Create public static instance.
54 82 *
@@ -82,8 +110,28 @@
82 110 );
83 111 }
84 112
85 113 /**
114 + * Resolve a batch group's controller.
115 + *
116 + * Module controllers register under a namespace key of their own
117 + * (the Cost Tracker uses mainwp/v2/costs), so a miss on the shared
118 + * namespace is retried under the group's key before it is treated
119 + * as an unregistered group.
120 + *
121 + * @param string $con_name Group name.
122 + * @return MainWP_REST_Controller|false
123 + */
124 + private function get_batch_controller( $con_name ) {
125 + $server = MainWP_Rest_Server::instance();
126 + $controller = $server->get_rest_controller( $this->namespace, $con_name );
127 + if ( false === $controller ) {
128 + $controller = $server->get_rest_controller( $this->namespace . '/' . $con_name, $con_name );
129 + }
130 + return $controller;
131 + }
132 +
133 + /**
86 134 * Bulk create, update and delete items.
87 135 *
88 136 * @param WP_REST_Request $request Full details about the request.
89 137 * @return array Of WP_Error or WP_REST_Response.
@@ -96,33 +144,95 @@
96 144 */
97 145 global $wp_rest_server;
98 146
99 147 // Get the request params.
100 - $items = array_filter( $request->get_params() );
101 - $query = $request->get_query_params();
102 - $response = array();
148 + $all_params = $request->get_params();
149 + $items = array_filter( $all_params );
150 + $query = $request->get_query_params();
151 + $response = array();
103 152
104 - // Check batch limit.
153 + $body_groups = $request->get_json_params();
154 + if ( empty( $body_groups ) || ! is_array( $body_groups ) ) {
155 + $body_groups = (array) $request->get_body_params();
156 + }
157 +
158 + // Groups the batch endpoint cannot dispatch (updates has no batch-capable create handler,
159 + // costs has no controller at all, anything else is unknown) are reported once each instead
160 + // of being dropped without a word or failing per item with a 405 from the core stub.
161 + //
162 + // The names come from the same merged params the limit check and the dispatch read, so a
163 + // group sent only in the query string is answered rather than counted in silence. A group is
164 + // always an array there, so scalar params (per_page, the auth keys) name none. The body is
165 + // read on top of that because a falsy group is filtered out of the items, and because for an
166 + // unsupported name the value does not matter, so a scalar body group is reported too.
167 + // Whichever source it came from, an underscore parameter is one WordPress reserves for the
168 + // REST server itself (_fields, _embed, _locale) and never a group the caller named.
169 + $groups = array();
170 + foreach ( $items as $param_name => $param_value ) {
171 + if ( is_array( $param_value ) && 0 !== strpos( (string) $param_name, '_' ) ) {
172 + $groups[ $param_name ] = true;
173 + }
174 + }
175 + foreach ( array_keys( $body_groups ) as $body_group_name ) {
176 + if ( 0 !== strpos( (string) $body_group_name, '_' ) ) {
177 + $groups[ $body_group_name ] = true;
178 + }
179 + }
180 +
181 + foreach ( array_keys( $groups ) as $group_name ) {
182 + if ( ! in_array( $group_name, $this->controller_names, true ) ) {
183 + $response[ $group_name ] = array(
184 + 'error' => array(
185 + 'code' => 'rest_batch_group_not_supported',
186 + /* translators: %s: batch group name */
187 + 'message' => sprintf( __( 'The %s group is not supported by the batch endpoint.', 'mainwp' ), $group_name ),
188 + 'data' => array( 'status' => 400 ),
189 + ),
190 + );
191 + }
192 + }
193 +
194 + // Shapes are checked against the items the dispatch below reads, not against the body, so a
195 + // group sent as a query parameter cannot skip the check. The group is dropped from the items
196 + // as well as reported, so nothing in it is dispatched.
197 + foreach ( $this->controller_names as $group_name ) {
198 + if ( isset( $items[ $group_name ] ) ) {
199 + $group_items = $items[ $group_name ];
200 + } elseif ( array_key_exists( $group_name, $body_groups ) ) {
201 + // A falsy group is filtered out of the items, so it is read back from the body to be
202 + // reported rather than passed over.
203 + $group_items = $body_groups[ $group_name ];
204 + } elseif ( array_key_exists( $group_name, $all_params ) ) {
205 + // A falsy group sent only in the query string is in neither of the above, and
206 + // skipping it answered the whole request with an empty success.
207 + $group_items = $all_params[ $group_name ];
208 + } else {
209 + continue;
210 + }
211 +
212 + if ( ! $this->is_dispatchable_group( $group_name, $group_items ) ) {
213 + $response[ $group_name ] = array(
214 + 'error' => array(
215 + 'code' => 'rest_invalid_param',
216 + /* translators: %s: batch group name */
217 + 'message' => sprintf( __( 'The %s group must be an object of supported action arrays.', 'mainwp' ), $group_name ),
218 + 'data' => array( 'status' => 400 ),
219 + ),
220 + );
221 + unset( $items[ $group_name ] );
222 + }
223 + }
224 +
225 + // Counted after the malformed groups are dropped: nothing in them is dispatched, so their items
226 + // must not be what pushes a request over the cap and hides the per-group error behind a 413.
105 227 $limit = $this->check_batch_limit( $items );
106 228 if ( is_wp_error( $limit ) ) {
107 229 return $limit;
108 230 }
109 231
110 - // The updates controller has no batch-capable create handler, so report the whole group once
111 - // instead of letting every item fail with a 405 from the WordPress core stub.
112 - if ( ! empty( $items['updates'] ) ) {
113 - $response['updates'] = array(
114 - 'error' => array(
115 - 'code' => 'rest_batch_group_not_supported',
116 - 'message' => __( 'The updates group is not supported by the batch endpoint.', 'mainwp' ),
117 - 'data' => array( 'status' => 400 ),
118 - ),
119 - );
120 - }
121 -
122 232 foreach ( $this->controller_names as $con_name ) {
123 233
124 - $controller_obj = MainWP_Rest_Server::instance()->get_rest_controller( $this->namespace, $con_name );
234 + $controller_obj = $this->get_batch_controller( $con_name );
125 235
126 236 if ( false === $controller_obj ) {
127 237 continue;
128 238 }
@@ -128,9 +238,8 @@
128 238 }
129 239
130 240 if ( ! empty( $items[ $con_name ]['create'] ) ) {
131 241 foreach ( $items[ $con_name ]['create'] as $item ) {
132 - $_item = new WP_REST_Request( 'POST', $request->get_route() );
133 242 // Default parameters.
134 243 $defaults = array();
135 244 $schema = $controller_obj->get_public_item_schema();
136 245
@@ -138,18 +247,12 @@
138 247 if ( isset( $options['default'] ) ) {
139 248 $defaults[ $arg ] = $options['default'];
140 249 }
141 250 }
142 - $_item->set_default_params( $defaults );
143 251
144 - // Set request parameters.
145 - $_item->set_body_params( $item );
252 + $_item = $this->prepare_batch_create_request( $request->get_route(), $item, $controller_obj->get_batch_create_args(), $defaults, $query );
253 + $_response = is_wp_error( $_item ) ? $_item : $controller_obj->create_item( $_item );
146 254
147 - // Set query (GET) parameters.
148 - $_item->set_query_params( $query );
149 -
150 - $_response = $controller_obj->create_item( $_item );
151 -
152 255 if ( is_wp_error( $_response ) ) {
153 256 $response[ $con_name ]['create'][] = array(
154 257 'id' => 0,
155 258 'error' => array(
@@ -162,8 +265,50 @@
162 265 $response[ $con_name ]['create'][] = $wp_rest_server->response_to_data( $_response, '' );
163 266 }
164 267 }
165 268 }
269 +
270 + // check_batch_limit() counts update and delete items for every group, so they
271 + // are dispatched here the same way the per-controller batch route dispatches them.
272 + if ( ! empty( $items[ $con_name ]['update'] ) ) {
273 + foreach ( $items[ $con_name ]['update'] as $item ) {
274 + $_item = $this->prepare_batch_update_request( $request->get_route(), $item, $controller_obj->get_batch_update_args() );
275 + $_response = is_wp_error( $_item ) ? $_item : $controller_obj->update_item( $_item );
276 +
277 + if ( is_wp_error( $_response ) ) {
278 + $response[ $con_name ]['update'][] = array(
279 + 'id' => is_array( $item ) ? $this->batch_error_id( $item['id'] ?? 0 ) : 0,
280 + 'error' => array(
281 + 'code' => $_response->get_error_code(),
282 + 'message' => $_response->get_error_message(),
283 + 'data' => $_response->get_error_data(),
284 + ),
285 + );
286 + } else {
287 + $response[ $con_name ]['update'][] = $wp_rest_server->response_to_data( $_response, '' );
288 + }
289 + }
290 + }
291 +
292 + if ( ! empty( $items[ $con_name ]['delete'] ) ) {
293 + foreach ( $items[ $con_name ]['delete'] as $id ) {
294 + $_item = $this->prepare_batch_id_request( $request->get_route(), $id, array( 'force' => true ), $controller_obj->get_batch_delete_args() );
295 + $_response = is_wp_error( $_item ) ? $_item : $controller_obj->delete_item( $_item );
296 +
297 + if ( is_wp_error( $_response ) ) {
298 + $response[ $con_name ]['delete'][] = array(
299 + 'id' => $this->batch_error_id( $id ),
300 + 'error' => array(
301 + 'code' => $_response->get_error_code(),
302 + 'message' => $_response->get_error_message(),
303 + 'data' => $_response->get_error_data(),
304 + ),
305 + );
306 + } else {
307 + $response[ $con_name ]['delete'][] = $wp_rest_server->response_to_data( $_response, '' );
308 + }
309 + }
310 + }
166 311 }
167 312
168 313 $con_name = 'sites';
169 314
@@ -171,25 +316,14 @@
171 316
172 317 if ( $controller_obj ) {
173 318 if ( ! empty( $items[ $con_name ]['sync'] ) ) {
174 319 foreach ( $items[ $con_name ]['sync'] as $id ) {
175 - $id = (int) $id;
320 + $_item = $this->prepare_batch_id_request( $request->get_route(), $id );
321 + $_response = is_wp_error( $_item ) ? $_item : $controller_obj->sync_item( $_item );
176 322
177 - if ( 0 === $id ) {
178 - continue;
179 - }
180 -
181 - $_item = new WP_REST_Request( 'DELETE', $request->get_route() );
182 - $_item->set_query_params(
183 - array(
184 - 'id' => $id,
185 - )
186 - );
187 - $_response = $controller_obj->sync_item( $_item );
188 -
189 323 if ( is_wp_error( $_response ) ) {
190 324 $response[ $con_name ]['sync'][] = array(
191 - 'id' => $id,
325 + 'id' => $this->batch_error_id( $id ),
192 326 'error' => array(
193 327 'code' => $_response->get_error_code(),
194 328 'message' => $_response->get_error_message(),
195 329 'data' => $_response->get_error_data(),
@@ -202,25 +336,14 @@
202 336 }
203 337
204 338 if ( ! empty( $items[ $con_name ]['reconnect'] ) ) {
205 339 foreach ( $items[ $con_name ]['reconnect'] as $id ) {
206 - $id = (int) $id;
340 + $_item = $this->prepare_batch_id_request( $request->get_route(), $id );
341 + $_response = is_wp_error( $_item ) ? $_item : $controller_obj->reconnect_item( $_item );
207 342
208 - if ( 0 === $id ) {
209 - continue;
210 - }
211 -
212 - $_item = new WP_REST_Request( 'DELETE', $request->get_route() );
213 - $_item->set_query_params(
214 - array(
215 - 'id' => $id,
216 - )
217 - );
218 - $_response = $controller_obj->reconnect_item( $_item );
219 -
220 343 if ( is_wp_error( $_response ) ) {
221 344 $response[ $con_name ]['reconnect'][] = array(
222 - 'id' => $id,
345 + 'id' => $this->batch_error_id( $id ),
223 346 'error' => array(
224 347 'code' => $_response->get_error_code(),
225 348 'message' => $_response->get_error_message(),
226 349 'data' => $_response->get_error_data(),
@@ -233,25 +356,14 @@
233 356 }
234 357
235 358 if ( ! empty( $items[ $con_name ]['disconnect'] ) ) {
236 359 foreach ( $items[ $con_name ]['disconnect'] as $id ) {
237 - $id = (int) $id;
360 + $_item = $this->prepare_batch_id_request( $request->get_route(), $id );
361 + $_response = is_wp_error( $_item ) ? $_item : $controller_obj->disconnect_site( $_item );
238 362
239 - if ( 0 === $id ) {
240 - continue;
241 - }
242 -
243 - $_item = new WP_REST_Request( 'DELETE', $request->get_route() );
244 - $_item->set_query_params(
245 - array(
246 - 'id' => $id,
247 - )
248 - );
249 - $_response = $controller_obj->disconnect_site( $_item );
250 -
251 363 if ( is_wp_error( $_response ) ) {
252 364 $response[ $con_name ]['disconnect'][] = array(
253 - 'id' => $id,
365 + 'id' => $this->batch_error_id( $id ),
254 366 'error' => array(
255 367 'code' => $_response->get_error_code(),
256 368 'message' => $_response->get_error_message(),
257 369 'data' => $_response->get_error_data(),
@@ -264,25 +376,14 @@
264 376 }
265 377
266 378 if ( ! empty( $items[ $con_name ]['suspend'] ) ) {
267 379 foreach ( $items[ $con_name ]['suspend'] as $id ) {
268 - $id = (int) $id;
380 + $_item = $this->prepare_batch_id_request( $request->get_route(), $id );
381 + $_response = is_wp_error( $_item ) ? $_item : $controller_obj->suspend_item( $_item );
269 382
270 - if ( 0 === $id ) {
271 - continue;
272 - }
273 -
274 - $_item = new WP_REST_Request( 'DELETE', $request->get_route() );
275 - $_item->set_query_params(
276 - array(
277 - 'id' => $id,
278 - )
279 - );
280 - $_response = $controller_obj->suspend_item( $_item );
281 -
282 383 if ( is_wp_error( $_response ) ) {
283 384 $response[ $con_name ]['suspend'][] = array(
284 - 'id' => $id,
385 + 'id' => $this->batch_error_id( $id ),
285 386 'error' => array(
286 387 'code' => $_response->get_error_code(),
287 388 'message' => $_response->get_error_message(),
288 389 'data' => $_response->get_error_data(),
@@ -295,25 +396,14 @@
295 396 }
296 397
297 398 if ( ! empty( $items[ $con_name ]['check'] ) ) {
298 399 foreach ( $items[ $con_name ]['check'] as $id ) {
299 - $id = (int) $id;
400 + $_item = $this->prepare_batch_id_request( $request->get_route(), $id );
401 + $_response = is_wp_error( $_item ) ? $_item : $controller_obj->check_item( $_item );
300 402
301 - if ( 0 === $id ) {
302 - continue;
303 - }
304 -
305 - $_item = new WP_REST_Request( 'DELETE', $request->get_route() );
306 - $_item->set_query_params(
307 - array(
308 - 'id' => $id,
309 - )
310 - );
311 - $_response = $controller_obj->check_item( $_item );
312 -
313 403 if ( is_wp_error( $_response ) ) {
314 404 $response[ $con_name ]['check'][] = array(
315 - 'id' => $id,
405 + 'id' => $this->batch_error_id( $id ),
316 406 'error' => array(
317 407 'code' => $_response->get_error_code(),
318 408 'message' => $_response->get_error_message(),
319 409 'data' => $_response->get_error_data(),
@@ -326,25 +416,14 @@
326 416 }
327 417
328 418 if ( ! empty( $items[ $con_name ]['remove'] ) ) {
329 419 foreach ( $items[ $con_name ]['remove'] as $id ) {
330 - $id = (int) $id;
420 + $_item = $this->prepare_batch_id_request( $request->get_route(), $id );
421 + $_response = is_wp_error( $_item ) ? $_item : $controller_obj->delete_item( $_item );
331 422
332 - if ( 0 === $id ) {
333 - continue;
334 - }
335 -
336 - $_item = new WP_REST_Request( 'DELETE', $request->get_route() );
337 - $_item->set_query_params(
338 - array(
339 - 'id' => $id,
340 - )
341 - );
342 - $_response = $controller_obj->delete_item( $_item );
343 -
344 423 if ( is_wp_error( $_response ) ) {
345 424 $response[ $con_name ]['remove'][] = array(
346 - 'id' => $id,
425 + 'id' => $this->batch_error_id( $id ),
347 426 'error' => array(
348 427 'code' => $_response->get_error_code(),
349 428 'message' => $_response->get_error_message(),
350 429 'data' => $_response->get_error_data(),
@@ -357,25 +436,14 @@
357 436 }
358 437
359 438 if ( ! empty( $items[ $con_name ]['security'] ) ) {
360 439 foreach ( $items[ $con_name ]['security'] as $id ) {
361 - $id = (int) $id;
440 + $_item = $this->prepare_batch_id_request( $request->get_route(), $id );
441 + $_response = is_wp_error( $_item ) ? $_item : $controller_obj->security_item( $_item );
362 442
363 - if ( 0 === $id ) {
364 - continue;
365 - }
366 -
367 - $_item = new WP_REST_Request( 'DELETE', $request->get_route() );
368 - $_item->set_query_params(
369 - array(
370 - 'id' => $id,
371 - )
372 - );
373 - $_response = $controller_obj->security_item( $_item );
374 -
375 443 if ( is_wp_error( $_response ) ) {
376 444 $response[ $con_name ]['security'][] = array(
377 - 'id' => $id,
445 + 'id' => $this->batch_error_id( $id ),
378 446 'error' => array(
379 447 'code' => $_response->get_error_code(),
380 448 'message' => $_response->get_error_message(),
381 449 'data' => $_response->get_error_data(),
@@ -388,25 +456,14 @@
388 456 }
389 457
390 458 if ( ! empty( $items[ $con_name ]['plugins'] ) ) {
391 459 foreach ( $items[ $con_name ]['plugins'] as $id ) {
392 - $id = (int) $id;
460 + $_item = $this->prepare_batch_id_request( $request->get_route(), $id );
461 + $_response = is_wp_error( $_item ) ? $_item : $controller_obj->get_site_plugins( $_item );
393 462
394 - if ( 0 === $id ) {
395 - continue;
396 - }
397 -
398 - $_item = new WP_REST_Request( 'DELETE', $request->get_route() );
399 - $_item->set_query_params(
400 - array(
401 - 'id' => $id,
402 - )
403 - );
404 - $_response = $controller_obj->get_site_plugins( $_item );
405 -
406 463 if ( is_wp_error( $_response ) ) {
407 464 $response[ $con_name ]['plugins'][] = array(
408 - 'id' => $id,
465 + 'id' => $this->batch_error_id( $id ),
409 466 'error' => array(
410 467 'code' => $_response->get_error_code(),
411 468 'message' => $_response->get_error_message(),
412 469 'data' => $_response->get_error_data(),
@@ -419,25 +476,14 @@
419 476 }
420 477
421 478 if ( ! empty( $items[ $con_name ]['themes'] ) ) {
422 479 foreach ( $items[ $con_name ]['themes'] as $id ) {
423 - $id = (int) $id;
480 + $_item = $this->prepare_batch_id_request( $request->get_route(), $id );
481 + $_response = is_wp_error( $_item ) ? $_item : $controller_obj->get_site_themes( $_item );
424 482
425 - if ( 0 === $id ) {
426 - continue;
427 - }
428 -
429 - $_item = new WP_REST_Request( 'DELETE', $request->get_route() );
430 - $_item->set_query_params(
431 - array(
432 - 'id' => $id,
433 - )
434 - );
435 - $_response = $controller_obj->get_site_themes( $_item );
436 -
437 483 if ( is_wp_error( $_response ) ) {
438 484 $response[ $con_name ]['themes'][] = array(
439 - 'id' => $id,
485 + 'id' => $this->batch_error_id( $id ),
440 486 'error' => array(
441 487 'code' => $_response->get_error_code(),
442 488 'message' => $_response->get_error_message(),
443 489 'data' => $_response->get_error_data(),
@@ -450,25 +496,14 @@
450 496 }
451 497
452 498 if ( ! empty( $items[ $con_name ]['non-mainwp-changes'] ) ) {
453 499 foreach ( $items[ $con_name ]['non-mainwp-changes'] as $id ) {
454 - $id = (int) $id;
500 + $_item = $this->prepare_batch_id_request( $request->get_route(), $id );
501 + $_response = is_wp_error( $_item ) ? $_item : $controller_obj->get_non_mainwp_changes_of_site( $_item );
455 502
456 - if ( 0 === $id ) {
457 - continue;
458 - }
459 -
460 - $_item = new WP_REST_Request( 'DELETE', $request->get_route() );
461 - $_item->set_query_params(
462 - array(
463 - 'id' => $id,
464 - )
465 - );
466 - $_response = $controller_obj->get_non_mainwp_changes_of_site( $_item );
467 -
468 503 if ( is_wp_error( $_response ) ) {
469 504 $response[ $con_name ]['non-mainwp-changes'][] = array(
470 - 'id' => $id,
505 + 'id' => $this->batch_error_id( $id ),
471 506 'error' => array(
472 507 'code' => $_response->get_error_code(),
473 508 'message' => $_response->get_error_message(),
474 509 'data' => $_response->get_error_data(),
@@ -485,8 +520,69 @@
485 520 }
486 521
487 522
488 523 /**
524 + * Check that a group carries only what the dispatch below can read.
525 + *
526 + * The dispatch indexes the group by action name and then walks each action list, so a scalar in
527 + * either place would reach a foreach over something that is not a list, a JSON list carries none
528 + * of the names it indexes by, and an action it does not read is silently dropped. Items are
529 + * checked too: a create item is passed on as a request body and an id item is cast to an int, so
530 + * neither can be an arbitrary value.
531 + *
532 + * @param string $group_name Group name.
533 + * @param mixed $group_items Group value taken from the request.
534 + * @return bool
535 + */
536 + private function is_dispatchable_group( $group_name, $group_items ) {
537 + if ( ! is_array( $group_items ) ) {
538 + return false;
539 + }
540 +
541 + // An empty group dispatches nothing and is not the caller getting the shape wrong. It is
542 + // taken out of the list test as well, which range() cannot answer for a count of zero.
543 + if ( array() === $group_items ) {
544 + return true;
545 + }
546 +
547 + if ( array_keys( $group_items ) === range( 0, count( $group_items ) - 1 ) ) {
548 + return false;
549 + }
550 +
551 + $actions = isset( self::GROUP_ACTIONS[ $group_name ] ) ? self::GROUP_ACTIONS[ $group_name ] : array();
552 +
553 + foreach ( $group_items as $action_name => $action_items ) {
554 + if ( ! isset( $actions[ $action_name ] ) || ! is_array( $action_items ) ) {
555 + return false;
556 + }
557 +
558 + // The dispatch walks an action as a list, so an object of named items would be
559 + // dispatched by its values with the names dropped and nothing said about the shape. An
560 + // empty action dispatches nothing, and range() cannot answer for a count of zero.
561 + if ( array() !== $action_items && array_keys( $action_items ) !== range( 0, count( $action_items ) - 1 ) ) {
562 + return false;
563 + }
564 +
565 + foreach ( $action_items as $item ) {
566 + if ( 'item' === $actions[ $action_name ] ) {
567 + if ( ! is_array( $item ) ) {
568 + return false;
569 + }
570 + } elseif ( ! ( is_int( $item ) && $item > 0 ) && ! ( is_string( $item ) && ctype_digit( $item ) && (int) $item > 0 && ltrim( $item, '0' ) === (string) (int) $item ) ) {
571 + // A fractional or exponent string is numeric, and the (int) cast below would read
572 + // it as a whole id the caller never asked for, so only digits are accepted. Zero
573 + // is refused with them: every site action skips an id that casts to 0, so the
574 + // batch would answer with neither an operation nor an error for that item. A digit
575 + // string past PHP_INT_MAX casts to PHP_INT_MAX, a different id than the one sent.
576 + return false;
577 + }
578 + }
579 + }
580 +
581 + return true;
582 + }
583 +
584 + /**
489 585 * Check batch limit.
490 586 *
491 587 * @param array $items Request items.
492 588 * @return bool|WP_Error
@@ -494,11 +590,12 @@
494 590 protected function check_batch_limit( $items ) { //phpcs:ignore -- NOSONAR complex function.
495 591 $limit = apply_filters( 'mainwp_rest_batch_items_limit', 100, $this->get_normalized_rest_base() );
496 592 $total = 0;
497 593
498 - // The updates group is rejected as a whole by batch_items(), but its items still count
499 - // toward the cap so an oversized request is refused before anything else is dispatched.
500 - $count_names = array_merge( $this->controller_names, array( 'updates' ) );
594 + // The updates and costs groups are rejected as a whole by batch_items(), but their items
595 + // still count toward the cap so an oversized request is refused before anything else is
596 + // dispatched.
597 + $count_names = array_merge( $this->controller_names, array( 'updates', 'costs' ) );
501 598
502 599 foreach ( $count_names as $con_name ) {
503 600 if ( ! empty( $items[ $con_name ] ) && is_countable( $items[ $con_name ] ) ) {
504 601 if ( ! empty( $items[ $con_name ]['create'] ) && is_countable( $items[ $con_name ]['create'] ) ) {