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 +271 -158 6.1.4 → 6.2.1 View file →
@@ -40,15 +40,43 @@
40 40 */
41 41 protected $rest_base = 'batch';
42 42
43 43 /**
44 - * Route base.
44 + * Controller names handled by the batch endpoint.
45 45 *
46 - * @var string
46 + * @var array
47 47 */
48 - protected $controller_names = array( 'sites', 'clients', 'updates', '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,13 +144,87 @@
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 }
@@ -108,9 +230,9 @@
108 230 }
109 231
110 232 foreach ( $this->controller_names as $con_name ) {
111 233
112 - $controller_obj = MainWP_Rest_Server::instance()->get_rest_controller( $this->namespace, $con_name );
234 + $controller_obj = $this->get_batch_controller( $con_name );
113 235
114 236 if ( false === $controller_obj ) {
115 237 continue;
116 238 }
@@ -116,9 +238,8 @@
116 238 }
117 239
118 240 if ( ! empty( $items[ $con_name ]['create'] ) ) {
119 241 foreach ( $items[ $con_name ]['create'] as $item ) {
120 - $_item = new WP_REST_Request( 'POST', $request->get_route() );
121 242 // Default parameters.
122 243 $defaults = array();
123 244 $schema = $controller_obj->get_public_item_schema();
124 245
@@ -126,18 +247,12 @@
126 247 if ( isset( $options['default'] ) ) {
127 248 $defaults[ $arg ] = $options['default'];
128 249 }
129 250 }
130 - $_item->set_default_params( $defaults );
131 251
132 - // Set request parameters.
133 - $_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 );
134 254
135 - // Set query (GET) parameters.
136 - $_item->set_query_params( $query );
137 -
138 - $_response = $controller_obj->create_item( $_item );
139 -
140 255 if ( is_wp_error( $_response ) ) {
141 256 $response[ $con_name ]['create'][] = array(
142 257 'id' => 0,
143 258 'error' => array(
@@ -150,8 +265,50 @@
150 265 $response[ $con_name ]['create'][] = $wp_rest_server->response_to_data( $_response, '' );
151 266 }
152 267 }
153 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 + }
154 311 }
155 312
156 313 $con_name = 'sites';
157 314
@@ -159,25 +316,14 @@
159 316
160 317 if ( $controller_obj ) {
161 318 if ( ! empty( $items[ $con_name ]['sync'] ) ) {
162 319 foreach ( $items[ $con_name ]['sync'] as $id ) {
163 - $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 );
164 322
165 - if ( 0 === $id ) {
166 - continue;
167 - }
168 -
169 - $_item = new WP_REST_Request( 'DELETE', $request->get_route() );
170 - $_item->set_query_params(
171 - array(
172 - 'id' => $id,
173 - )
174 - );
175 - $_response = $controller_obj->sync_item( $_item );
176 -
177 323 if ( is_wp_error( $_response ) ) {
178 324 $response[ $con_name ]['sync'][] = array(
179 - 'id' => $id,
325 + 'id' => $this->batch_error_id( $id ),
180 326 'error' => array(
181 327 'code' => $_response->get_error_code(),
182 328 'message' => $_response->get_error_message(),
183 329 'data' => $_response->get_error_data(),
@@ -190,25 +336,14 @@
190 336 }
191 337
192 338 if ( ! empty( $items[ $con_name ]['reconnect'] ) ) {
193 339 foreach ( $items[ $con_name ]['reconnect'] as $id ) {
194 - $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 );
195 342
196 - if ( 0 === $id ) {
197 - continue;
198 - }
199 -
200 - $_item = new WP_REST_Request( 'DELETE', $request->get_route() );
201 - $_item->set_query_params(
202 - array(
203 - 'id' => $id,
204 - )
205 - );
206 - $_response = $controller_obj->reconnect_item( $_item );
207 -
208 343 if ( is_wp_error( $_response ) ) {
209 344 $response[ $con_name ]['reconnect'][] = array(
210 - 'id' => $id,
345 + 'id' => $this->batch_error_id( $id ),
211 346 'error' => array(
212 347 'code' => $_response->get_error_code(),
213 348 'message' => $_response->get_error_message(),
214 349 'data' => $_response->get_error_data(),
@@ -221,25 +356,14 @@
221 356 }
222 357
223 358 if ( ! empty( $items[ $con_name ]['disconnect'] ) ) {
224 359 foreach ( $items[ $con_name ]['disconnect'] as $id ) {
225 - $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 );
226 362
227 - if ( 0 === $id ) {
228 - continue;
229 - }
230 -
231 - $_item = new WP_REST_Request( 'DELETE', $request->get_route() );
232 - $_item->set_query_params(
233 - array(
234 - 'id' => $id,
235 - )
236 - );
237 - $_response = $controller_obj->disconnect_site( $_item );
238 -
239 363 if ( is_wp_error( $_response ) ) {
240 364 $response[ $con_name ]['disconnect'][] = array(
241 - 'id' => $id,
365 + 'id' => $this->batch_error_id( $id ),
242 366 'error' => array(
243 367 'code' => $_response->get_error_code(),
244 368 'message' => $_response->get_error_message(),
245 369 'data' => $_response->get_error_data(),
@@ -252,25 +376,14 @@
252 376 }
253 377
254 378 if ( ! empty( $items[ $con_name ]['suspend'] ) ) {
255 379 foreach ( $items[ $con_name ]['suspend'] as $id ) {
256 - $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 );
257 382
258 - if ( 0 === $id ) {
259 - continue;
260 - }
261 -
262 - $_item = new WP_REST_Request( 'DELETE', $request->get_route() );
263 - $_item->set_query_params(
264 - array(
265 - 'id' => $id,
266 - )
267 - );
268 - $_response = $controller_obj->suspend_item( $_item );
269 -
270 383 if ( is_wp_error( $_response ) ) {
271 384 $response[ $con_name ]['suspend'][] = array(
272 - 'id' => $id,
385 + 'id' => $this->batch_error_id( $id ),
273 386 'error' => array(
274 387 'code' => $_response->get_error_code(),
275 388 'message' => $_response->get_error_message(),
276 389 'data' => $_response->get_error_data(),
@@ -283,25 +396,14 @@
283 396 }
284 397
285 398 if ( ! empty( $items[ $con_name ]['check'] ) ) {
286 399 foreach ( $items[ $con_name ]['check'] as $id ) {
287 - $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 );
288 402
289 - if ( 0 === $id ) {
290 - continue;
291 - }
292 -
293 - $_item = new WP_REST_Request( 'DELETE', $request->get_route() );
294 - $_item->set_query_params(
295 - array(
296 - 'id' => $id,
297 - )
298 - );
299 - $_response = $controller_obj->check_item( $_item );
300 -
301 403 if ( is_wp_error( $_response ) ) {
302 404 $response[ $con_name ]['check'][] = array(
303 - 'id' => $id,
405 + 'id' => $this->batch_error_id( $id ),
304 406 'error' => array(
305 407 'code' => $_response->get_error_code(),
306 408 'message' => $_response->get_error_message(),
307 409 'data' => $_response->get_error_data(),
@@ -314,25 +416,14 @@
314 416 }
315 417
316 418 if ( ! empty( $items[ $con_name ]['remove'] ) ) {
317 419 foreach ( $items[ $con_name ]['remove'] as $id ) {
318 - $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 );
319 422
320 - if ( 0 === $id ) {
321 - continue;
322 - }
323 -
324 - $_item = new WP_REST_Request( 'DELETE', $request->get_route() );
325 - $_item->set_query_params(
326 - array(
327 - 'id' => $id,
328 - )
329 - );
330 - $_response = $controller_obj->delete_item( $_item );
331 -
332 423 if ( is_wp_error( $_response ) ) {
333 424 $response[ $con_name ]['remove'][] = array(
334 - 'id' => $id,
425 + 'id' => $this->batch_error_id( $id ),
335 426 'error' => array(
336 427 'code' => $_response->get_error_code(),
337 428 'message' => $_response->get_error_message(),
338 429 'data' => $_response->get_error_data(),
@@ -345,25 +436,14 @@
345 436 }
346 437
347 438 if ( ! empty( $items[ $con_name ]['security'] ) ) {
348 439 foreach ( $items[ $con_name ]['security'] as $id ) {
349 - $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 );
350 442
351 - if ( 0 === $id ) {
352 - continue;
353 - }
354 -
355 - $_item = new WP_REST_Request( 'DELETE', $request->get_route() );
356 - $_item->set_query_params(
357 - array(
358 - 'id' => $id,
359 - )
360 - );
361 - $_response = $controller_obj->security_item( $_item );
362 -
363 443 if ( is_wp_error( $_response ) ) {
364 444 $response[ $con_name ]['security'][] = array(
365 - 'id' => $id,
445 + 'id' => $this->batch_error_id( $id ),
366 446 'error' => array(
367 447 'code' => $_response->get_error_code(),
368 448 'message' => $_response->get_error_message(),
369 449 'data' => $_response->get_error_data(),
@@ -376,25 +456,14 @@
376 456 }
377 457
378 458 if ( ! empty( $items[ $con_name ]['plugins'] ) ) {
379 459 foreach ( $items[ $con_name ]['plugins'] as $id ) {
380 - $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 );
381 462
382 - if ( 0 === $id ) {
383 - continue;
384 - }
385 -
386 - $_item = new WP_REST_Request( 'DELETE', $request->get_route() );
387 - $_item->set_query_params(
388 - array(
389 - 'id' => $id,
390 - )
391 - );
392 - $_response = $controller_obj->get_site_plugins( $_item );
393 -
394 463 if ( is_wp_error( $_response ) ) {
395 464 $response[ $con_name ]['plugins'][] = array(
396 - 'id' => $id,
465 + 'id' => $this->batch_error_id( $id ),
397 466 'error' => array(
398 467 'code' => $_response->get_error_code(),
399 468 'message' => $_response->get_error_message(),
400 469 'data' => $_response->get_error_data(),
@@ -407,25 +476,14 @@
407 476 }
408 477
409 478 if ( ! empty( $items[ $con_name ]['themes'] ) ) {
410 479 foreach ( $items[ $con_name ]['themes'] as $id ) {
411 - $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 );
412 482
413 - if ( 0 === $id ) {
414 - continue;
415 - }
416 -
417 - $_item = new WP_REST_Request( 'DELETE', $request->get_route() );
418 - $_item->set_query_params(
419 - array(
420 - 'id' => $id,
421 - )
422 - );
423 - $_response = $controller_obj->get_site_themes( $_item );
424 -
425 483 if ( is_wp_error( $_response ) ) {
426 484 $response[ $con_name ]['themes'][] = array(
427 - 'id' => $id,
485 + 'id' => $this->batch_error_id( $id ),
428 486 'error' => array(
429 487 'code' => $_response->get_error_code(),
430 488 'message' => $_response->get_error_message(),
431 489 'data' => $_response->get_error_data(),
@@ -438,25 +496,14 @@
438 496 }
439 497
440 498 if ( ! empty( $items[ $con_name ]['non-mainwp-changes'] ) ) {
441 499 foreach ( $items[ $con_name ]['non-mainwp-changes'] as $id ) {
442 - $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 );
443 502
444 - if ( 0 === $id ) {
445 - continue;
446 - }
447 -
448 - $_item = new WP_REST_Request( 'DELETE', $request->get_route() );
449 - $_item->set_query_params(
450 - array(
451 - 'id' => $id,
452 - )
453 - );
454 - $_response = $controller_obj->get_non_mainwp_changes_of_site( $_item );
455 -
456 503 if ( is_wp_error( $_response ) ) {
457 504 $response[ $con_name ]['non-mainwp-changes'][] = array(
458 - 'id' => $id,
505 + 'id' => $this->batch_error_id( $id ),
459 506 'error' => array(
460 507 'code' => $_response->get_error_code(),
461 508 'message' => $_response->get_error_message(),
462 509 'data' => $_response->get_error_data(),
@@ -473,8 +520,69 @@
473 520 }
474 521
475 522
476 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 + /**
477 585 * Check batch limit.
478 586 *
479 587 * @param array $items Request items.
480 588 * @return bool|WP_Error
@@ -482,9 +590,14 @@
482 590 protected function check_batch_limit( $items ) { //phpcs:ignore -- NOSONAR complex function.
483 591 $limit = apply_filters( 'mainwp_rest_batch_items_limit', 100, $this->get_normalized_rest_base() );
484 592 $total = 0;
485 593
486 - foreach ( $this->controller_names as $con_name ) {
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' ) );
598 +
599 + foreach ( $count_names as $con_name ) {
487 600 if ( ! empty( $items[ $con_name ] ) && is_countable( $items[ $con_name ] ) ) {
488 601 if ( ! empty( $items[ $con_name ]['create'] ) && is_countable( $items[ $con_name ]['create'] ) ) {
489 602 $total += count( $items[ $con_name ]['create'] );
490 603 }