PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / trunk
WCPOS – Point of Sale (POS) plugin for WooCommerce vtrunk
1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 1.9.13 1.9.12 1.9.11 1.9.10 1.9.9 All 158 releases
woocommerce-pos / includes / API.php

API.php in WCPOS – Point of Sale (POS) plugin for WooCommerce trunk, at includes/API.php

721 lines 27.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WCPOS REST API class.
4 *
5 * @author Paul Kilmurray <paul@kilbot.com>
6 *
7 * @see http://wcpos.com
8 * @package WCPOS\WooCommercePOS
9 */
10
11 namespace WCPOS\WooCommercePOS;
12
13 use WCPOS\WooCommercePOS\Services\Auth;
14 use WCPOS\WooCommercePOS\Services\Client_Signal;
15 use WCPOS\WooCommercePOS\Services\Settings as SettingsService;
16 use WP_HTTP_Response;
17 use WP_REST_Request;
18 use WP_REST_Response;
19 use WP_REST_Server;
20
21 /**
22 * API class.
23 */
24 class API {
25 /**
26 * WCPOS REST API namespaces.
27 */
28 public const ROUTE_NAMESPACES = array( 'wcpos/v1', 'wcpos/v2' );
29
30 /**
31 * WCPOS REST API namespaces and endpoints.
32 *
33 * @var array
34 */
35 protected $controllers = array();
36
37 /**
38 * Map of route patterns to controller keys.
39 * Built during register_routes() for use in rest_dispatch_request().
40 *
41 * @var array<string, string>
42 */
43 protected $route_map = array();
44
45 /**
46 * Route permission-gate classifier.
47 *
48 * @var API\Route_Classifier
49 */
50 protected $route_classifier;
51
52 /**
53 * Flag to check if authentication has been checked.
54 *
55 * @var bool
56 */
57 protected $is_auth_checked = false;
58
59 /**
60 * Flag to track whether WCPOS successfully authenticated the current request
61 * via its own Bearer token. Used to suppress errors from third-party JWT
62 * plugins that inspected the same Authorization header but could not validate
63 * a WCPOS-issued token with their own secret.
64 *
65 * @var bool
66 */
67 protected $authenticated_via_wcpos = false;
68
69 /**
70 * Constructor.
71 */
72 public function __construct() {
73 $this->register_routes();
74
75 /*
76 * Adds authentication to for JWT bearer tokens
77 * - We run determine_current_user at 20 to allow other plugins to run first
78 */
79 add_filter( 'determine_current_user', array( $this, 'determine_current_user' ), 20 );
80 add_filter( 'rest_authentication_errors', array( $this, 'rest_authentication_errors' ), 50, 1 );
81
82 // Adds info about the WordPress install.
83 add_filter( 'rest_index', array( $this, 'rest_index' ), 10, 1 );
84
85 // These filters allow changes to the WC REST API response.
86 add_filter( 'rest_dispatch_request', array( $this, 'rest_dispatch_request' ), 10, 4 );
87 add_filter( 'rest_pre_dispatch', array( $this, 'rest_pre_dispatch' ), 10, 3 );
88 add_filter( 'rest_pre_dispatch', array( $this, 'clear_third_party_jwt_error' ), 50, 3 );
89 add_filter( 'rest_post_dispatch', array( $this, 'rest_post_dispatch' ), 10, 3 );
90 }
91
92 /**
93 * Get the WCPOS REST API namespaces.
94 *
95 * @return string[] REST API namespaces.
96 */
97 public function get_route_namespaces(): array {
98 /**
99 * Filter the list of namespaces used in the WCPOS REST API.
100 *
101 * This filter is strictly additive: plugins can register additional WCPOS REST
102 * API namespaces, but the core namespaces cannot be removed — the central
103 * permission gate must keep covering every registered core route. Controllers
104 * remain responsible for declaring any special route classifications within
105 * added namespaces.
106 *
107 * @since 1.10.0
108 *
109 * @param string[] $namespaces REST API namespaces.
110 */
111 $namespaces = apply_filters( 'woocommerce_pos_rest_namespaces', self::ROUTE_NAMESPACES );
112
113 return array_values( array_unique( array_merge( self::ROUTE_NAMESPACES, (array) $namespaces ) ) );
114 }
115
116 /**
117 * Register routes for all controllers.
118 */
119 public function register_routes(): void {
120 $route_namespaces = $this->get_route_namespaces();
121 $this->route_classifier = new API\Route_Classifier( $route_namespaces );
122
123 /**
124 * Filter the list of controller classes used in the WCPOS REST API.
125 *
126 * This filter allows customizing or extending the set of controller classes that handle
127 * REST API routes for the WCPOS. By filtering these controllers, plugins can
128 * modify existing endpoints or add new controllers for additional functionality.
129 * Core legacy controllers use their versioned WCPOS\WooCommercePOS\API\V1 FQCNs.
130 *
131 * @since 1.5.0
132 *
133 * @param array $controllers Associative array of controller identifiers to their corresponding class names.
134 * - 'auth' => Fully qualified name of the class handling authentication.
135 * - 'settings' => Fully qualified name of the class handling settings.
136 * - 'cashier' => Fully qualified name of the class handling cashier management.
137 * - 'products' => Fully qualified name of the class handling products.
138 * - 'product_variations' => Fully qualified name of the class handling product variations.
139 * - 'orders' => Fully qualified name of the class handling orders.
140 * - 'customers' => Fully qualified name of the class handling customers.
141 * - 'product_tags' => Fully qualified name of the class handling product tags.
142 * - 'product_categories' => Fully qualified name of the class handling product categories.
143 * - 'taxes' => Fully qualified name of the class handling taxes.
144 * - 'shipping_methods' => Fully qualified name of the class handling shipping methods.
145 * - 'tax_classes' => Fully qualified name of the class handling tax classes.
146 * - 'order_statuses' => Fully qualified name of the class handling order statuses.
147 */
148 $classes = apply_filters(
149 'woocommerce_pos_rest_api_controllers',
150 array(
151 // WCPOS rest api controllers.
152 'auth' => API\V1\Auth::class,
153 'settings' => API\V1\Settings::class,
154 'cashier' => API\V1\Cashier::class,
155 'templates' => API\V1\Templates_Controller::class,
156 'receipts' => API\V1\Receipts_Controller::class,
157 'print_jobs' => API\V1\Print_Jobs_Controller::class,
158
159 // TODO: remove this?
160 'stores' => API\V1\Stores::class,
161 'extensions' => API\V1\Extensions::class,
162 'logs' => API\V1\Logs::class,
163 'payment_gateways' => API\V1\Payment_Gateways::class,
164 'gateway_bootstrap' => API\V1\Gateway_Bootstrap_Controller::class,
165 'checkout' => API\V1\Checkout_Controller::class,
166
167 // extend WC REST API controllers.
168 'products' => API\V1\Products_Controller::class,
169 'product_variations' => API\V1\Product_Variations_Controller::class,
170 'orders' => API\V1\Orders_Controller::class,
171 'customers' => API\V1\Customers_Controller::class,
172 'product_tags' => API\V1\Product_Tags_Controller::class,
173 'product_categories' => API\V1\Product_Categories_Controller::class,
174 'product_brands' => API\V1\Product_Brands_Controller::class,
175 'coupons' => API\V1\Coupons_Controller::class,
176 'taxes' => API\V1\Taxes_Controller::class,
177 'shipping_methods' => API\V1\Shipping_Methods_Controller::class,
178 'tax_classes' => API\V1\Tax_Classes_Controller::class,
179 'order_statuses' => API\V1\Data_Order_Statuses_Controller::class,
180 )
181 );
182
183 /**
184 * Filter the wcpos/v2 service pass-through controllers (additive to the
185 * frozen v1 surface — the legacy data controllers stay v1-only).
186 *
187 * Extensions that replace a v1 service through
188 * `woocommerce_pos_rest_api_controllers` must carry their service onto
189 * the v2 surface here with their own pass-through subclass (override
190 * `$namespace = 'wcpos/v2'`), exactly as core does — the v2 map is not
191 * derived from the v1 map, so a v1 replacement alone leaves the v2
192 * twin serving core behavior.
193 *
194 * @since 1.10.0
195 *
196 * @param array $controllers Associative array of v2 service controller class names.
197 */
198 $v2_classes = apply_filters(
199 'woocommerce_pos_rest_api_v2_controllers',
200 array(
201 'ping' => API\V2\Ping::class,
202 'echo_probe' => API\V2\Echo_Probe::class,
203 'site' => API\V2\Site::class,
204 'auth' => API\V2\Auth::class,
205 'settings' => API\V2\Settings::class,
206 'cashier' => API\V2\Cashier::class,
207 'templates' => API\V2\Templates_Controller::class,
208 'receipts' => API\V2\Receipts_Controller::class,
209 'print_jobs' => API\V2\Print_Jobs_Controller::class,
210 'stores' => API\V2\Stores::class,
211 'extensions' => API\V2\Extensions::class,
212 'logs' => API\V2\Logs::class,
213 'payment_gateways' => API\V2\Payment_Gateways::class,
214 'gateway_bootstrap' => API\V2\Gateway_Bootstrap_Controller::class,
215 'checkout' => API\V2\Checkout_Controller::class,
216 'order_email' => API\V2\Order_Email_Controller::class,
217 'shipping_methods' => API\V2\Shipping_Methods_Controller::class,
218 'tax_classes' => API\V2\Tax_Classes_Controller::class,
219 'order_statuses' => API\V2\Data_Order_Statuses_Controller::class,
220 )
221 );
222 foreach ( $v2_classes as $key => $class ) {
223 $classes[ 'v2-' . $key ] = $class;
224 }
225 $legacy_classifications = array(
226 'auth' => array( 'public' => array( '/wcpos/v1/auth/test', '/wcpos/v1/auth/refresh' ) ),
227 'print_jobs' => array( 'printer_token' => array( '/wcpos/v1/print-jobs/cloudprnt', '/wcpos/v1/print-jobs/epson-sdp' ) ),
228 'receipts' => array( 'permission_error_passthrough' => array( '/wcpos/v1/receipts/' ) ),
229 );
230
231 foreach ( $classes as $key => $class ) {
232 if ( class_exists( $class ) ) {
233 $this->controllers[ $key ] = new $class();
234 $this->controllers[ $key ]->register_routes();
235
236 if ( method_exists( $this->controllers[ $key ], 'wcpos_route_classifications' ) ) {
237 $this->route_classifier->merge( $this->controllers[ $key ]->wcpos_route_classifications() );
238 } elseif ( isset( $legacy_classifications[ $key ] ) ) {
239 $this->route_classifier->merge( $legacy_classifications[ $key ] );
240 }
241 }
242 }
243
244 // Sync classifications are independent of feature-gated route registration.
245 $this->route_classifier->merge( Sync\Api::route_classifications() );
246
247 // Build route map for use in rest_dispatch_request().
248 $rest_server = rest_get_server();
249
250 foreach ( $route_namespaces as $route_namespace ) {
251 $all_routes = $rest_server->get_routes( $route_namespace );
252
253 foreach ( $all_routes as $route_pattern => $route_handlers ) {
254 foreach ( $route_handlers as $route_handler ) {
255 $callback = $route_handler['callback'] ?? null;
256
257 // Extract the controller object from the callback.
258 $controller_obj = null;
259 if ( \is_array( $callback ) && isset( $callback[0] ) && \is_object( $callback[0] ) ) {
260 $controller_obj = $callback[0];
261 } elseif ( $callback instanceof \Closure ) {
262 // WC 10.5+ RestApiCache wraps callbacks in closures.
263 // Use reflection to extract the bound $this.
264 $ref = new \ReflectionFunction( $callback );
265 $controller_obj = $ref->getClosureThis();
266 }
267
268 if ( ! $controller_obj ) {
269 continue;
270 }
271
272 // Find which controller key this object belongs to.
273 foreach ( $this->controllers as $key => $registered_controller ) {
274 if ( $controller_obj === $registered_controller ) {
275 $this->route_map[ $route_pattern ] = $key;
276 break;
277 }
278 }
279 }
280 }
281 }
282 }
283
284 /**
285 * Check request for any login tokens.
286 *
287 * Runs at priority 20, after other plugins (e.g. third-party JWT plugins at
288 * priority 10) have had a chance to authenticate the user. If another plugin
289 * already returned a valid user ID, we trust it. Otherwise we attempt our own
290 * WCPOS Bearer-token authentication.
291 *
292 * Note: some JWT plugins pass a WP_Error through this filter when they fail to
293 * validate a Bearer token. We treat that the same as false so we can still
294 * authenticate the request with our own JWT.
295 *
296 * Note: this filter may not be called at all when WordPress has already cached
297 * the current user (WooCommerce issue #26847). The rest_authentication_errors
298 * fallback below handles that scenario.
299 *
300 * @param false|int|\WP_Error $user_id User ID if one has been determined, false otherwise.
301 *
302 * @return false|int
303 */
304 public function determine_current_user( $user_id ) {
305 $this->is_auth_checked = true;
306
307 // Trust a valid user ID set by another plugin (e.g. JWT plugin with its own token).
308 // Treat a WP_Error the same as false — another plugin rejected its own token,
309 // but we should still attempt authentication with our Bearer token.
310 if ( ! empty( $user_id ) && ! is_wp_error( $user_id ) ) {
311 return $user_id;
312 }
313
314 $result = $this->authenticate( false );
315 if ( $result && ! is_wp_error( $result ) ) {
316 $this->authenticated_via_wcpos = true;
317 return $result;
318 }
319
320 // If neither we nor another plugin authenticated the user, return false
321 // (not authenticated) rather than a WP_Error from $user_id. WordPress core
322 // expects determine_current_user to return false|int, not WP_Error.
323 // The JWT plugin's error will surface via rest_authentication_errors instead.
324 return is_wp_error( $user_id ) ? false : $user_id;
325 }
326
327 /**
328 * Handles two distinct failure modes:
329 *
330 * 1. WooCommerce issue #26847: determine_current_user may not be called when
331 * WordPress has already cached the current user. We attempt auth here as a
332 * fallback.
333 *
334 * 2. JWT plugin conflict: a third-party JWT plugin sees our Bearer token, fails
335 * to validate it with its own secret, and returns a WP_Error via
336 * rest_authentication_errors at priority 10. We run at priority 50 and attempt
337 * our own Bearer-token validation. If it succeeds, we clear the stale error —
338 * our authentication wins. (jwt-authentication-for-wp-rest-api surfaces its
339 * error through rest_pre_dispatch instead; see clear_third_party_jwt_error().)
340 *
341 * @param mixed $errors Authentication errors.
342 *
343 * @return mixed
344 */
345 public function rest_authentication_errors( $errors ) {
346 // If there is already an error from a previous filter (e.g. a JWT plugin that
347 // rejected our Bearer token), attempt WCPOS authentication before passing it
348 // through. This covers the case where determine_current_user was skipped
349 // (WC #26847) or where the JWT plugin ran at a higher priority.
350 if ( ! empty( $errors ) ) {
351 // Only clear errors that originate from JWT authentication plugins. Errors
352 // from other mechanisms (maintenance locks, IP restrictions, etc.) should
353 // be passed through even when the WCPOS Bearer token is valid.
354 if ( $this->is_third_party_jwt_error( $errors ) && $this->ensure_authenticated_via_wcpos() ) {
355 return null;
356 }
357
358 return $errors;
359 }
360
361 // check if determine_current_user has been called.
362 if ( ! $this->is_auth_checked && $this->ensure_authenticated_via_wcpos() ) {
363 // Authentication hadn't occurred during `determine_current_user`, but our token is valid.
364 return true;
365 }
366
367 return $errors;
368 }
369
370 /**
371 * Clear a third-party JWT plugin's stale error from the dispatch result.
372 *
373 * The plugin jwt-authentication-for-wp-rest-api (verified at 1.5.0) validates every
374 * Bearer token in determine_current_user (priority 10) with its own secret. Ours fails,
375 * so it stores a `jwt_auth_invalid_token` WP_Error and returns the user untouched;
376 * our priority-20 filter then authenticates the request. The plugin later returns
377 * that stored error from rest_pre_dispatch (priority 10, registered at
378 * plugins_loaded), which replaces the dispatch result with a 403.
379 *
380 * Priority 50: after the plugin's callback, and after our own priority-10
381 * permission gate, whose `woocommerce_pos_rest_*` errors must pass through untouched.
382 *
383 * Unlike rest_authentication_errors(), this never switches the current user: the
384 * priority-10 gate and the core-order audit guard have already judged the user in
385 * scope, so the error is cleared only when our token resolves to that same user.
386 *
387 * @param mixed $result Dispatch result, or null to not hijack the request.
388 * @param WP_REST_Server $server Server instance.
389 * @param WP_REST_Request $request Request used to generate the response.
390 *
391 * @return mixed
392 */
393 public function clear_third_party_jwt_error( $result, $server, $request ) {
394 if ( ! $this->is_third_party_jwt_error( $result ) ) {
395 return $result;
396 }
397
398 if ( ! $this->authenticated_via_wcpos ) {
399 $user_id = $this->authenticate( false );
400 if ( $user_id && ! is_wp_error( $user_id ) && get_current_user_id() === (int) $user_id ) {
401 $this->authenticated_via_wcpos = true;
402 }
403 }
404
405 return $this->authenticated_via_wcpos ? null : $result;
406 }
407
408 /**
409 * Whether a value is a WP_Error raised by a third-party JWT plugin (`jwt_auth_*`).
410 *
411 * @param mixed $maybe_error Value to inspect.
412 *
413 * @return bool
414 */
415 private function is_third_party_jwt_error( $maybe_error ): bool {
416 return is_wp_error( $maybe_error ) && 0 === strpos( $maybe_error->get_error_code(), 'jwt_auth_' );
417 }
418
419 /**
420 * Authenticate the request with its WCPOS Bearer token if that hasn't happened yet.
421 *
422 * @return bool True when the request is authenticated via a WCPOS-issued token.
423 */
424 private function ensure_authenticated_via_wcpos(): bool {
425 if ( ! $this->authenticated_via_wcpos ) {
426 $user_id = $this->authenticate( false );
427 if ( $user_id && ! is_wp_error( $user_id ) ) {
428 wp_set_current_user( $user_id );
429 $this->authenticated_via_wcpos = true;
430 }
431 }
432
433 return $this->authenticated_via_wcpos;
434 }
435
436 /**
437 * Extract the Authorization Bearer token from the request.
438 *
439 * @return false|string
440 */
441 public function get_auth_header() {
442 return Auth::instance()->get_auth_header();
443 }
444
445 /**
446 * Adds info to the WP REST API index response.
447 * - UUID
448 * - Version Info.
449 *
450 * @param WP_REST_Response $response Response data.
451 *
452 * @return WP_REST_Response
453 */
454 public function rest_index( WP_REST_Response $response ): WP_REST_Response {
455 $uuid = wcpos_get_site_uuid();
456 $response->data['uuid'] = $uuid;
457 $response->data['wp_version'] = get_bloginfo( 'version' );
458 $response->data['wc_version'] = WC()->version;
459 $response->data['wcpos_version'] = VERSION;
460 $response->data['use_jwt_as_param'] = SettingsService::instance()->use_jwt_as_param_enabled();
461
462 // Add WCPOS authentication endpoint to the response.
463 $response->data['authentication']['wcpos'] = array(
464 'endpoints' => array(
465 'authorization' => Template_Router::get_auth_url(),
466 ),
467 );
468
469 /**
470 * Remove the routes from the response.
471 *
472 * Some wordpress sites have a huge number of routes, like 2MB of data. It shouldn;t matter, but it seems
473 * to cause issues with the desktop application sometimes. We don't use the routes at the moment, so we
474 * can remove them from the response.
475 */
476 $data = $response->get_data();
477 unset( $data['routes'] );
478 $response->set_data( $data );
479
480 return $response;
481 }
482
483 /**
484 * Filters the pre-calculated result of a REST API dispatch request.
485 *
486 * Allow hijacking the request before dispatching by returning a non-empty. The returned value
487 * will be used to serve the request instead.
488 *
489 * @param mixed $result Response to replace the requested version with. Can be anything
490 * a normal endpoint can return, or null to not hijack the request.
491 * @param WP_REST_Server $server Server instance.
492 * @param WP_REST_Request $request Request used to generate the response.
493 *
494 * @return mixed
495 */
496 public function rest_pre_dispatch( $result, $server, $request ) {
497 if ( ! $this->route_classifier->in_wcpos_namespace( $request->get_route() ) ) {
498 return $result;
499 }
500
501 // Marker-gated on purpose (query var or header, NOT the rest_route arm,
502 // which matches this namespace by construction): every real POS client,
503 // old or new, carries the marker, while unmarked scanner traffic would
504 // otherwise inflate the `channel: none` tail this telemetry exists to
505 // measure (free#1752). The echo and auth lanes are excluded for the same
506 // reason: they are the gate's carve-outs, and a protocol-2 client's
507 // connect-time probes deliberately carry no signal — counting them would
508 // stamp every modern client with a daily false `none` row.
509 if ( 0 === stripos( $request->get_route(), '/wcpos/v2/' )
510 && 1 !== preg_match( '#^/wcpos/v2/(?:echo$|auth(?:/|$))#i', $request->get_route() )
511 && ( wcpos_request( 'query_var' ) || wcpos_request( 'header' ) ) ) {
512 try {
513 Client_Signal::record( $request );
514 } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Telemetry failures are deliberately ignored.
515 // Telemetry must never interrupt a POS request.
516 }
517 }
518
519 // Latch the till's store scope for the whole request (pro#425). Set
520 // unconditionally — including to null — so a scope never leaks from one
521 // dispatch into the next. Inner `wc/v3` forwards do not reach this line
522 // (they are outside the WCPOS namespace), which is exactly right: the
523 // OUTER request owns the scope and stamps it onto the inner ones.
524 \WCPOS\WooCommercePOS\Sync\Store_Scope::set_current(
525 \WCPOS\WooCommercePOS\Sync\Store_Scope::resolve( $request )
526 );
527
528 // CORS preflights carry no credentials (browsers strip Authorization from OPTIONS),
529 // so the permission gate must never answer them with 401 — a non-2xx preflight blocks
530 // every cross-origin standalone client from the entire namespace. WP core serves
531 // OPTIONS with route metadata and Rest_Cors::rest_pre_serve_request adds the CORS headers.
532 if ( 'OPTIONS' === $request->get_method() ) {
533 return $result;
534 }
535
536 // Baseline permission gate: POS endpoints require access_woocommerce_pos; the three
537 // sync admin operations instead use their route-level manage_woocommerce check.
538 // Exempt public auth, printer-token polling, and authenticated receipt denials that need
539 // the receipt-specific error code.
540 $route = $request->get_route();
541 $has_route_specific_permission_error = is_user_logged_in() && $this->route_classifier->is_permission_error_passthrough( $route );
542 $is_public_auth_route = $this->route_classifier->is_public( $route );
543 $is_printer_token_route = $this->route_classifier->is_printer_token( $route );
544 $is_sync_admin_route = is_user_logged_in() && current_user_can( 'manage_woocommerce' ) && $this->route_classifier->is_admin_op( $route );
545
546 if ( ! $is_public_auth_route && ! $has_route_specific_permission_error && ! $is_printer_token_route && ! $is_sync_admin_route ) {
547 if ( ! current_user_can( 'access_woocommerce_pos' ) ) {
548 if ( ! is_user_logged_in() ) {
549 return new \WP_Error(
550 'woocommerce_pos_rest_unauthorized',
551 __( 'Authentication required.', 'woocommerce-pos' ),
552 array( 'status' => 401 )
553 );
554 }
555
556 return new \WP_Error(
557 'woocommerce_pos_rest_forbidden',
558 __( 'You do not have permission to access the POS.', 'woocommerce-pos' ),
559 array( 'status' => 403 )
560 );
561 }
562 }
563
564 $max_length = 10000;
565
566 // The sync sub-surface speaks its own wire contract (include = raw id
567 // list validated by its controllers); the wcpos_include/exclude rewrite
568 // below is a legacy extended-WC-controller workaround and must not
569 // mangle sync routes.
570 if ( $this->route_classifier->is_rewrite_exempt( $route ) ) {
571 return $result;
572 }
573
574 // Process 'include' parameter.
575 $include = $request->get_param( 'include' );
576 if ( $include ) {
577 $processed_include = $this->shorten_param_array( $include, $max_length );
578 $request->set_param( 'wcpos_include', $processed_include );
579 unset( $request['include'] );
580 }
581
582 // Process 'exclude' parameter.
583 $exclude = $request->get_param( 'exclude' );
584 if ( $exclude ) {
585 $processed_exclude = $this->shorten_param_array( $exclude, $max_length );
586 $request->set_param( 'wcpos_exclude', $processed_exclude );
587 unset( $request['exclude'] );
588 }
589
590 return $result;
591 }
592
593 /**
594 * Add the server pressure bucket to WCPOS REST responses.
595 *
596 * @param mixed $response REST response.
597 * @param WP_REST_Server $server REST server.
598 * @param WP_REST_Request $request REST request.
599 *
600 * @return mixed
601 */
602 public function rest_post_dispatch( $response, $server, $request ) {
603 if ( is_wp_error( $response ) ) {
604 return $response;
605 }
606
607 try {
608 if ( ! $response instanceof WP_HTTP_Response || ! $this->route_classifier->in_wcpos_namespace( $request->get_route() ) ) {
609 return $response;
610 }
611 $pressure_bucket = API\V2\Ping::pressure_bucket();
612 if ( null !== $pressure_bucket ) {
613 $response->header( 'X-WCPOS-Pressure', $pressure_bucket );
614 }
615 } catch ( \Throwable $e ) {
616 return $response;
617 }
618
619 return $response;
620 }
621
622 /**
623 * Filters the REST API dispatch request result.
624 *
625 * @param mixed $dispatch_result Dispatch result, will be used if not empty.
626 * @param WP_REST_Request $request Request used to generate the response.
627 * @param string $route Route matched for the request.
628 * @param array $handler Route handler used for the request.
629 *
630 * @return mixed
631 */
632 public function rest_dispatch_request( $dispatch_result, $request, $route, $handler ) {
633 // Only process mapped WCPOS routes.
634 if ( ! isset( $this->route_map[ $route ] ) ) {
635 return $dispatch_result;
636 }
637
638 /*
639 * POS-specific PHP settings to prevent errors in JSON and float weirdness.
640 *
641 * - error_reporting(0) - Turn off error reporting
642 * - ini_set('display_errors', 0) - Turn off error display
643 * - ini_set('precision', 10) - Set the precision of floating point numbers
644 * - ini_set('serialize_precision', 10) - Set the precision of floating point numbers for serialization
645 *
646 * This is to prevent any PHP errors from being displayed in the response.
647 *
648 * The precision settings are to prevent floating point weirdness, eg: stock_quantity 3.6 becomes 3.6000000000000001
649 */
650 error_reporting( 0 );
651 @ini_set( 'display_errors', '0' ); // phpcs:ignore WordPress.PHP.IniSet.display_errors_Disallowed -- intentionally disabling error display for POS API responses.
652 @ini_set( 'precision', '10' );
653 @ini_set( 'serialize_precision', '10' );
654
655 $key = $this->route_map[ $route ];
656 $controller = $this->controllers[ $key ] ?? null;
657
658 if ( $controller && method_exists( $controller, 'wcpos_dispatch_request' ) ) {
659 return $controller->wcpos_dispatch_request( $dispatch_result, $request, $route, $handler );
660 }
661
662 return $dispatch_result;
663 }
664
665 /**
666 * Some servers have a limit on the number of include/exclude we can use in a request.
667 * Worst thing is there is often no error message, the request returns an empty response.
668 *
669 * For example, WP Engine has a limit of 1024 characters?
670 * https://wpengine.com/support/using-dev-tools/#Long_Queries_in_wp_db
671 *
672 * @TODO - For long queries, I should find a better solution than this.
673 *
674 * @param array|string $param_value The parameter value.
675 * @param int $max_length The maximum length.
676 *
677 * @return array
678 */
679 private function shorten_param_array( $param_value, $max_length ) {
680 $param_array = \is_array( $param_value ) ? $param_value : explode( ',', $param_value );
681 $param_string = implode( ',', $param_array );
682
683 if ( \strlen( $param_string ) > $max_length ) {
684 shuffle( $param_array ); // Shuffle to randomize.
685
686 $new_param_string = '';
687 $random_param_array = array();
688
689 foreach ( $param_array as $id ) {
690 if ( \strlen( $new_param_string . $id ) < $max_length ) {
691 $new_param_string .= $id . ',';
692 $random_param_array[] = $id;
693 } else {
694 break; // Stop when maximum length is reached.
695 }
696 }
697
698 return $random_param_array;
699 }
700
701 return $param_array;
702 }
703
704 /**
705 * Check the Authorization header for a Bearer token.
706 *
707 * @param false|int $user_id User ID if one has been determined, false otherwise.
708 *
709 * @return false|int|\WP_Error
710 */
711 private function authenticate( $user_id ) {
712 $authenticated_user_id = Auth::instance()->authenticate_request();
713
714 if ( is_wp_error( $authenticated_user_id ) ) {
715 return false === $user_id ? $authenticated_user_id : $user_id;
716 }
717
718 return false === $authenticated_user_id ? $user_id : $authenticated_user_id;
719 }
720 }
721