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 +276 -158 5.2 → 6.2.1 View file →
@@ -6,8 +6,13 @@
6 6 *
7 7 * @package MainWP\Dashboard
8 8 */
9 9
10 +// Exit if accessed directly.
11 +if ( ! defined( 'ABSPATH' ) ) {
12 + exit;
13 +}
14 +
10 15 /**
11 16 * Class MainWP_Rest_Global_Batch_Controller
12 17 *
13 18 * @package MainWP\Dashboard
@@ -35,15 +40,43 @@
35 40 */
36 41 protected $rest_base = 'batch';
37 42
38 43 /**
39 - * Route base.
44 + * Controller names handled by the batch endpoint.
40 45 *
41 - * @var string
46 + * @var array
42 47 */
43 - protected $controller_names = array( 'sites', 'clients', 'updates', 'costs', 'tags' );
48 + protected $controller_names = array( 'sites', 'clients', 'tags' );
44 49
45 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 + /**
46 79 * Method instance()
47 80 *
48 81 * Create public static instance.
49 82 *
@@ -77,8 +110,28 @@
77 110 );
78 111 }
79 112
80 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 + /**
81 134 * Bulk create, update and delete items.
82 135 *
83 136 * @param WP_REST_Request $request Full details about the request.
84 137 * @return array Of WP_Error or WP_REST_Response.
@@ -91,13 +144,87 @@
91 144 */
92 145 global $wp_rest_server;
93 146
94 147 // Get the request params.
95 - $items = array_filter( $request->get_params() );
96 - $query = $request->get_query_params();
97 - $response = array();
148 + $all_params = $request->get_params();
149 + $items = array_filter( $all_params );
150 + $query = $request->get_query_params();
151 + $response = array();
98 152
99 - // 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.
100 227 $limit = $this->check_batch_limit( $items );
101 228 if ( is_wp_error( $limit ) ) {
102 229 return $limit;
103 230 }
@@ -103,9 +230,9 @@
103 230 }
104 231
105 232 foreach ( $this->controller_names as $con_name ) {
106 233
107 - $controller_obj = MainWP_Rest_Server::instance()->get_rest_controller( $this->namespace, $con_name );
234 + $controller_obj = $this->get_batch_controller( $con_name );
108 235
109 236 if ( false === $controller_obj ) {
110 237 continue;
111 238 }
@@ -111,9 +238,8 @@
111 238 }
112 239
113 240 if ( ! empty( $items[ $con_name ]['create'] ) ) {
114 241 foreach ( $items[ $con_name ]['create'] as $item ) {
115 - $_item = new WP_REST_Request( 'POST', $request->get_route() );
116 242 // Default parameters.
117 243 $defaults = array();
118 244 $schema = $controller_obj->get_public_item_schema();
119 245
@@ -121,18 +247,12 @@
121 247 if ( isset( $options['default'] ) ) {
122 248 $defaults[ $arg ] = $options['default'];
123 249 }
124 250 }
125 - $_item->set_default_params( $defaults );
126 251
127 - // Set request parameters.
128 - $_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 );
129 254
130 - // Set query (GET) parameters.
131 - $_item->set_query_params( $query );
132 -
133 - $_response = $controller_obj->create_item( $_item );
134 -
135 255 if ( is_wp_error( $_response ) ) {
136 256 $response[ $con_name ]['create'][] = array(
137 257 'id' => 0,
138 258 'error' => array(
@@ -145,8 +265,50 @@
145 265 $response[ $con_name ]['create'][] = $wp_rest_server->response_to_data( $_response, '' );
146 266 }
147 267 }
148 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 + }
149 311 }
150 312
151 313 $con_name = 'sites';
152 314
@@ -154,25 +316,14 @@
154 316
155 317 if ( $controller_obj ) {
156 318 if ( ! empty( $items[ $con_name ]['sync'] ) ) {
157 319 foreach ( $items[ $con_name ]['sync'] as $id ) {
158 - $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 );
159 322
160 - if ( 0 === $id ) {
161 - continue;
162 - }
163 -
164 - $_item = new WP_REST_Request( 'DELETE', $request->get_route() );
165 - $_item->set_query_params(
166 - array(
167 - 'id' => $id,
168 - )
169 - );
170 - $_response = $controller_obj->sync_item( $_item );
171 -
172 323 if ( is_wp_error( $_response ) ) {
173 324 $response[ $con_name ]['sync'][] = array(
174 - 'id' => $id,
325 + 'id' => $this->batch_error_id( $id ),
175 326 'error' => array(
176 327 'code' => $_response->get_error_code(),
177 328 'message' => $_response->get_error_message(),
178 329 'data' => $_response->get_error_data(),
@@ -185,25 +336,14 @@
185 336 }
186 337
187 338 if ( ! empty( $items[ $con_name ]['reconnect'] ) ) {
188 339 foreach ( $items[ $con_name ]['reconnect'] as $id ) {
189 - $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 );
190 342
191 - if ( 0 === $id ) {
192 - continue;
193 - }
194 -
195 - $_item = new WP_REST_Request( 'DELETE', $request->get_route() );
196 - $_item->set_query_params(
197 - array(
198 - 'id' => $id,
199 - )
200 - );
201 - $_response = $controller_obj->reconnect_item( $_item );
202 -
203 343 if ( is_wp_error( $_response ) ) {
204 344 $response[ $con_name ]['reconnect'][] = array(
205 - 'id' => $id,
345 + 'id' => $this->batch_error_id( $id ),
206 346 'error' => array(
207 347 'code' => $_response->get_error_code(),
208 348 'message' => $_response->get_error_message(),
209 349 'data' => $_response->get_error_data(),
@@ -216,25 +356,14 @@
216 356 }
217 357
218 358 if ( ! empty( $items[ $con_name ]['disconnect'] ) ) {
219 359 foreach ( $items[ $con_name ]['disconnect'] as $id ) {
220 - $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 );
221 362
222 - if ( 0 === $id ) {
223 - continue;
224 - }
225 -
226 - $_item = new WP_REST_Request( 'DELETE', $request->get_route() );
227 - $_item->set_query_params(
228 - array(
229 - 'id' => $id,
230 - )
231 - );
232 - $_response = $controller_obj->disconnect_site( $_item );
233 -
234 363 if ( is_wp_error( $_response ) ) {
235 364 $response[ $con_name ]['disconnect'][] = array(
236 - 'id' => $id,
365 + 'id' => $this->batch_error_id( $id ),
237 366 'error' => array(
238 367 'code' => $_response->get_error_code(),
239 368 'message' => $_response->get_error_message(),
240 369 'data' => $_response->get_error_data(),
@@ -247,25 +376,14 @@
247 376 }
248 377
249 378 if ( ! empty( $items[ $con_name ]['suspend'] ) ) {
250 379 foreach ( $items[ $con_name ]['suspend'] as $id ) {
251 - $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 );
252 382
253 - if ( 0 === $id ) {
254 - continue;
255 - }
256 -
257 - $_item = new WP_REST_Request( 'DELETE', $request->get_route() );
258 - $_item->set_query_params(
259 - array(
260 - 'id' => $id,
261 - )
262 - );
263 - $_response = $controller_obj->suspend_item( $_item );
264 -
265 383 if ( is_wp_error( $_response ) ) {
266 384 $response[ $con_name ]['suspend'][] = array(
267 - 'id' => $id,
385 + 'id' => $this->batch_error_id( $id ),
268 386 'error' => array(
269 387 'code' => $_response->get_error_code(),
270 388 'message' => $_response->get_error_message(),
271 389 'data' => $_response->get_error_data(),
@@ -278,25 +396,14 @@
278 396 }
279 397
280 398 if ( ! empty( $items[ $con_name ]['check'] ) ) {
281 399 foreach ( $items[ $con_name ]['check'] as $id ) {
282 - $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 );
283 402
284 - if ( 0 === $id ) {
285 - continue;
286 - }
287 -
288 - $_item = new WP_REST_Request( 'DELETE', $request->get_route() );
289 - $_item->set_query_params(
290 - array(
291 - 'id' => $id,
292 - )
293 - );
294 - $_response = $controller_obj->check_item( $_item );
295 -
296 403 if ( is_wp_error( $_response ) ) {
297 404 $response[ $con_name ]['check'][] = array(
298 - 'id' => $id,
405 + 'id' => $this->batch_error_id( $id ),
299 406 'error' => array(
300 407 'code' => $_response->get_error_code(),
301 408 'message' => $_response->get_error_message(),
302 409 'data' => $_response->get_error_data(),
@@ -309,25 +416,14 @@
309 416 }
310 417
311 418 if ( ! empty( $items[ $con_name ]['remove'] ) ) {
312 419 foreach ( $items[ $con_name ]['remove'] as $id ) {
313 - $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 );
314 422
315 - if ( 0 === $id ) {
316 - continue;
317 - }
318 -
319 - $_item = new WP_REST_Request( 'DELETE', $request->get_route() );
320 - $_item->set_query_params(
321 - array(
322 - 'id' => $id,
323 - )
324 - );
325 - $_response = $controller_obj->delete_item( $_item );
326 -
327 423 if ( is_wp_error( $_response ) ) {
328 424 $response[ $con_name ]['remove'][] = array(
329 - 'id' => $id,
425 + 'id' => $this->batch_error_id( $id ),
330 426 'error' => array(
331 427 'code' => $_response->get_error_code(),
332 428 'message' => $_response->get_error_message(),
333 429 'data' => $_response->get_error_data(),
@@ -340,25 +436,14 @@
340 436 }
341 437
342 438 if ( ! empty( $items[ $con_name ]['security'] ) ) {
343 439 foreach ( $items[ $con_name ]['security'] as $id ) {
344 - $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 );
345 442
346 - if ( 0 === $id ) {
347 - continue;
348 - }
349 -
350 - $_item = new WP_REST_Request( 'DELETE', $request->get_route() );
351 - $_item->set_query_params(
352 - array(
353 - 'id' => $id,
354 - )
355 - );
356 - $_response = $controller_obj->security_item( $_item );
357 -
358 443 if ( is_wp_error( $_response ) ) {
359 444 $response[ $con_name ]['security'][] = array(
360 - 'id' => $id,
445 + 'id' => $this->batch_error_id( $id ),
361 446 'error' => array(
362 447 'code' => $_response->get_error_code(),
363 448 'message' => $_response->get_error_message(),
364 449 'data' => $_response->get_error_data(),
@@ -371,25 +456,14 @@
371 456 }
372 457
373 458 if ( ! empty( $items[ $con_name ]['plugins'] ) ) {
374 459 foreach ( $items[ $con_name ]['plugins'] as $id ) {
375 - $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 );
376 462
377 - if ( 0 === $id ) {
378 - continue;
379 - }
380 -
381 - $_item = new WP_REST_Request( 'DELETE', $request->get_route() );
382 - $_item->set_query_params(
383 - array(
384 - 'id' => $id,
385 - )
386 - );
387 - $_response = $controller_obj->get_site_plugins( $_item );
388 -
389 463 if ( is_wp_error( $_response ) ) {
390 464 $response[ $con_name ]['plugins'][] = array(
391 - 'id' => $id,
465 + 'id' => $this->batch_error_id( $id ),
392 466 'error' => array(
393 467 'code' => $_response->get_error_code(),
394 468 'message' => $_response->get_error_message(),
395 469 'data' => $_response->get_error_data(),
@@ -402,25 +476,14 @@
402 476 }
403 477
404 478 if ( ! empty( $items[ $con_name ]['themes'] ) ) {
405 479 foreach ( $items[ $con_name ]['themes'] as $id ) {
406 - $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 );
407 482
408 - if ( 0 === $id ) {
409 - continue;
410 - }
411 -
412 - $_item = new WP_REST_Request( 'DELETE', $request->get_route() );
413 - $_item->set_query_params(
414 - array(
415 - 'id' => $id,
416 - )
417 - );
418 - $_response = $controller_obj->get_site_themes( $_item );
419 -
420 483 if ( is_wp_error( $_response ) ) {
421 484 $response[ $con_name ]['themes'][] = array(
422 - 'id' => $id,
485 + 'id' => $this->batch_error_id( $id ),
423 486 'error' => array(
424 487 'code' => $_response->get_error_code(),
425 488 'message' => $_response->get_error_message(),
426 489 'data' => $_response->get_error_data(),
@@ -433,25 +496,14 @@
433 496 }
434 497
435 498 if ( ! empty( $items[ $con_name ]['non-mainwp-changes'] ) ) {
436 499 foreach ( $items[ $con_name ]['non-mainwp-changes'] as $id ) {
437 - $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 );
438 502
439 - if ( 0 === $id ) {
440 - continue;
441 - }
442 -
443 - $_item = new WP_REST_Request( 'DELETE', $request->get_route() );
444 - $_item->set_query_params(
445 - array(
446 - 'id' => $id,
447 - )
448 - );
449 - $_response = $controller_obj->get_non_mainwp_changes_of_site( $_item );
450 -
451 503 if ( is_wp_error( $_response ) ) {
452 504 $response[ $con_name ]['non-mainwp-changes'][] = array(
453 - 'id' => $id,
505 + 'id' => $this->batch_error_id( $id ),
454 506 'error' => array(
455 507 'code' => $_response->get_error_code(),
456 508 'message' => $_response->get_error_message(),
457 509 'data' => $_response->get_error_data(),
@@ -468,8 +520,69 @@
468 520 }
469 521
470 522
471 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 + /**
472 585 * Check batch limit.
473 586 *
474 587 * @param array $items Request items.
475 588 * @return bool|WP_Error
@@ -477,9 +590,14 @@
477 590 protected function check_batch_limit( $items ) { //phpcs:ignore -- NOSONAR complex function.
478 591 $limit = apply_filters( 'mainwp_rest_batch_items_limit', 100, $this->get_normalized_rest_base() );
479 592 $total = 0;
480 593
481 - 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 ) {
482 600 if ( ! empty( $items[ $con_name ] ) && is_countable( $items[ $con_name ] ) ) {
483 601 if ( ! empty( $items[ $con_name ]['create'] ) && is_countable( $items[ $con_name ]['create'] ) ) {
484 602 $total += count( $items[ $con_name ]['create'] );
485 603 }