PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.19
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.19
1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 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 All 163 releases
woocommerce-pos / includes / API.php

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

583 lines 20.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\API\Controller_Registry;
14 use WCPOS\WooCommercePOS\Services\Auth;
15 use WCPOS\WooCommercePOS\Services\Client_Signal;
16 use WCPOS\WooCommercePOS\Services\Settings as SettingsService;
17 use WP_HTTP_Response;
18 use WP_REST_Request;
19 use WP_REST_Response;
20 use WP_REST_Server;
21
22 /**
23 * API class.
24 */
25 class API {
26 /**
27 * WCPOS REST API namespaces.
28 */
29 public const ROUTE_NAMESPACES = array( 'wcpos/v1', 'wcpos/v2' );
30
31 /**
32 * Controller instances and route attribution.
33 *
34 * @var Controller_Registry
35 */
36 protected Controller_Registry $registry;
37
38 /**
39 * Route permission-gate classifier.
40 *
41 * @var API\Route_Classifier
42 */
43 protected $route_classifier;
44
45 /**
46 * Flag to check if authentication has been checked.
47 *
48 * @var bool
49 */
50 protected $is_auth_checked = false;
51
52 /**
53 * Validation error for a token presented during this request.
54 *
55 * @var \WP_Error|null
56 */
57 private $auth_error = null;
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 $this->registry = new Controller_Registry();
124 $this->registry->register( $this->route_classifier );
125
126 // Sync classifications are independent of feature-gated route registration.
127 $this->route_classifier->merge( Sync\Api::route_classifications() );
128 }
129
130 /**
131 * Check request for any login tokens.
132 *
133 * Runs at priority 20, after other plugins (e.g. third-party JWT plugins at
134 * priority 10) have had a chance to authenticate the user. If another plugin
135 * already returned a valid user ID, we trust it. Otherwise we attempt our own
136 * WCPOS Bearer-token authentication.
137 *
138 * Note: some JWT plugins pass a WP_Error through this filter when they fail to
139 * validate a Bearer token. We treat that the same as false so we can still
140 * authenticate the request with our own JWT.
141 *
142 * Note: this filter may not be called at all when WordPress has already cached
143 * the current user (WooCommerce issue #26847). The rest_authentication_errors
144 * fallback below handles that scenario.
145 *
146 * @param false|int|\WP_Error $user_id User ID if one has been determined, false otherwise.
147 *
148 * @return false|int
149 */
150 public function determine_current_user( $user_id ) {
151 $this->is_auth_checked = true;
152
153 // Trust a valid user ID set by another plugin (e.g. JWT plugin with its own token).
154 // Treat a WP_Error the same as false — another plugin rejected its own token,
155 // but we should still attempt authentication with our Bearer token.
156 if ( ! empty( $user_id ) && ! is_wp_error( $user_id ) ) {
157 return $user_id;
158 }
159
160 $result = $this->authenticate( false );
161 if ( $result && ! is_wp_error( $result ) ) {
162 $this->authenticated_via_wcpos = true;
163 return $result;
164 }
165
166 // If neither we nor another plugin authenticated the user, return false
167 // (not authenticated) rather than a WP_Error from $user_id. WordPress core
168 // expects determine_current_user to return false|int, not WP_Error.
169 // The JWT plugin's error will surface via rest_authentication_errors instead.
170 return is_wp_error( $user_id ) ? false : $user_id;
171 }
172
173 /**
174 * Handles two distinct failure modes:
175 *
176 * 1. WooCommerce issue #26847: determine_current_user may not be called when
177 * WordPress has already cached the current user. We attempt auth here as a
178 * fallback.
179 *
180 * 2. JWT plugin conflict: a third-party JWT plugin sees our Bearer token, fails
181 * to validate it with its own secret, and returns a WP_Error via
182 * rest_authentication_errors at priority 10. We run at priority 50 and attempt
183 * our own Bearer-token validation. If it succeeds, we clear the stale error —
184 * our authentication wins. (jwt-authentication-for-wp-rest-api surfaces its
185 * error through rest_pre_dispatch instead; see clear_third_party_jwt_error().)
186 *
187 * @param mixed $errors Authentication errors.
188 *
189 * @return mixed
190 */
191 public function rest_authentication_errors( $errors ) {
192 // If there is already an error from a previous filter (e.g. a JWT plugin that
193 // rejected our Bearer token), attempt WCPOS authentication before passing it
194 // through. This covers the case where determine_current_user was skipped
195 // (WC #26847) or where the JWT plugin ran at a higher priority.
196 if ( ! empty( $errors ) ) {
197 // Only clear errors that originate from JWT authentication plugins. Errors
198 // from other mechanisms (maintenance locks, IP restrictions, etc.) should
199 // be passed through even when the WCPOS Bearer token is valid.
200 if ( $this->is_third_party_jwt_error( $errors ) && $this->ensure_authenticated_via_wcpos() ) {
201 return null;
202 }
203
204 return $errors;
205 }
206
207 // check if determine_current_user has been called.
208 if ( ! $this->is_auth_checked && $this->ensure_authenticated_via_wcpos() ) {
209 // Authentication hadn't occurred during `determine_current_user`, but our token is valid.
210 return true;
211 }
212
213 return $errors;
214 }
215
216 /**
217 * Clear a third-party JWT plugin's stale error from the dispatch result.
218 *
219 * The plugin jwt-authentication-for-wp-rest-api (verified at 1.5.0) validates every
220 * Bearer token in determine_current_user (priority 10) with its own secret. Ours fails,
221 * so it stores a `jwt_auth_invalid_token` WP_Error and returns the user untouched;
222 * our priority-20 filter then authenticates the request. The plugin later returns
223 * that stored error from rest_pre_dispatch (priority 10, registered at
224 * plugins_loaded), which replaces the dispatch result with a 403.
225 *
226 * Priority 50: after the plugin's callback, and after our own priority-10
227 * permission gate, whose `woocommerce_pos_rest_*` errors must pass through untouched.
228 *
229 * Unlike rest_authentication_errors(), this never switches the current user: the
230 * priority-10 gate and the core-order audit guard have already judged the user in
231 * scope, so the error is cleared only when our token resolves to that same user.
232 *
233 * @param mixed $result Dispatch result, or null to not hijack the request.
234 * @param WP_REST_Server $server Server instance.
235 * @param WP_REST_Request $request Request used to generate the response.
236 *
237 * @return mixed
238 */
239 public function clear_third_party_jwt_error( $result, $server, $request ) {
240 if ( ! $this->is_third_party_jwt_error( $result ) ) {
241 return $result;
242 }
243
244 if ( ! $this->authenticated_via_wcpos ) {
245 $user_id = $this->authenticate( false );
246 if ( $user_id && ! is_wp_error( $user_id ) && get_current_user_id() === (int) $user_id ) {
247 $this->authenticated_via_wcpos = true;
248 }
249 }
250
251 return $this->authenticated_via_wcpos ? null : $result;
252 }
253
254 /**
255 * Whether a value is a WP_Error raised by a third-party JWT plugin (`jwt_auth_*`).
256 *
257 * @param mixed $maybe_error Value to inspect.
258 *
259 * @return bool
260 */
261 private function is_third_party_jwt_error( $maybe_error ): bool {
262 return is_wp_error( $maybe_error ) && 0 === strpos( $maybe_error->get_error_code(), 'jwt_auth_' );
263 }
264
265 /**
266 * Authenticate the request with its WCPOS Bearer token if that hasn't happened yet.
267 *
268 * @return bool True when the request is authenticated via a WCPOS-issued token.
269 */
270 private function ensure_authenticated_via_wcpos(): bool {
271 if ( ! $this->authenticated_via_wcpos ) {
272 $user_id = $this->authenticate( false );
273 if ( $user_id && ! is_wp_error( $user_id ) ) {
274 wp_set_current_user( $user_id );
275 $this->authenticated_via_wcpos = true;
276 }
277 }
278
279 return $this->authenticated_via_wcpos;
280 }
281
282 /**
283 * Extract the Authorization Bearer token from the request.
284 *
285 * @return false|string
286 */
287 public function get_auth_header() {
288 return Auth::instance()->get_auth_header();
289 }
290
291 /**
292 * Adds info to the WP REST API index response.
293 * - UUID
294 * - Version Info.
295 *
296 * @param WP_REST_Response $response Response data.
297 *
298 * @return WP_REST_Response
299 */
300 public function rest_index( WP_REST_Response $response ): WP_REST_Response {
301 $uuid = wcpos_get_site_uuid();
302 $response->data['uuid'] = $uuid;
303 $response->data['wp_version'] = get_bloginfo( 'version' );
304 $response->data['wc_version'] = WC()->version;
305 $response->data['wcpos_version'] = VERSION;
306 $response->data['use_jwt_as_param'] = SettingsService::instance()->use_jwt_as_param_enabled();
307
308 // Add WCPOS authentication endpoint to the response.
309 $response->data['authentication']['wcpos'] = array(
310 'endpoints' => array(
311 'authorization' => Template_Router::get_auth_url(),
312 ),
313 );
314
315 /**
316 * Remove the routes from the response.
317 *
318 * Some wordpress sites have a huge number of routes, like 2MB of data. It shouldn;t matter, but it seems
319 * to cause issues with the desktop application sometimes. We don't use the routes at the moment, so we
320 * can remove them from the response.
321 */
322 $data = $response->get_data();
323 unset( $data['routes'] );
324 $response->set_data( $data );
325
326 return $response;
327 }
328
329 /**
330 * Filters the pre-calculated result of a REST API dispatch request.
331 *
332 * Allow hijacking the request before dispatching by returning a non-empty. The returned value
333 * will be used to serve the request instead.
334 *
335 * @param mixed $result Response to replace the requested version with. Can be anything
336 * a normal endpoint can return, or null to not hijack the request.
337 * @param WP_REST_Server $server Server instance.
338 * @param WP_REST_Request $request Request used to generate the response.
339 *
340 * @return mixed
341 */
342 public function rest_pre_dispatch( $result, $server, $request ) {
343 if ( ! $this->route_classifier->in_wcpos_namespace( $request->get_route() ) ) {
344 return $result;
345 }
346
347 // Marker-gated on purpose (query var or header, NOT the rest_route arm,
348 // which matches this namespace by construction): every real POS client,
349 // old or new, carries the marker, while unmarked scanner traffic would
350 // otherwise inflate the `channel: none` tail this telemetry exists to
351 // measure (free#1752). The echo and auth lanes are excluded for the same
352 // reason: they are the gate's carve-outs, and a protocol-2 client's
353 // connect-time probes deliberately carry no signal — counting them would
354 // stamp every modern client with a daily false `none` row.
355 if ( 0 === stripos( $request->get_route(), '/wcpos/v2/' )
356 && 1 !== preg_match( '#^/wcpos/v2/(?:echo$|auth(?:/|$))#i', $request->get_route() )
357 && ( wcpos_request( 'query_var' ) || wcpos_request( 'header' ) ) ) {
358 try {
359 Client_Signal::record( $request );
360 } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Telemetry failures are deliberately ignored.
361 // Telemetry must never interrupt a POS request.
362 }
363 }
364
365 // Latch the till's store scope for the whole request (pro#425). Set
366 // unconditionally — including to null — so a scope never leaks from one
367 // dispatch into the next. Inner `wc/v3` forwards do not reach this line
368 // (they are outside the WCPOS namespace), which is exactly right: the
369 // OUTER request owns the scope and stamps it onto the inner ones.
370 \WCPOS\WooCommercePOS\Sync\Store_Scope::set_current(
371 \WCPOS\WooCommercePOS\Sync\Store_Scope::resolve( $request )
372 );
373
374 // CORS preflights carry no credentials (browsers strip Authorization from OPTIONS),
375 // so the permission gate must never answer them with 401 — a non-2xx preflight blocks
376 // every cross-origin standalone client from the entire namespace. WP core serves
377 // OPTIONS with route metadata and Rest_Cors::rest_pre_serve_request adds the CORS headers.
378 if ( 'OPTIONS' === $request->get_method() ) {
379 return $result;
380 }
381
382 // Baseline permission gate: POS endpoints require access_woocommerce_pos; the three
383 // sync admin operations instead use their route-level manage_woocommerce check.
384 // Exempt public auth, printer-token polling, and authenticated receipt denials that need
385 // the receipt-specific error code.
386 $route = $request->get_route();
387 $has_route_specific_permission_error = is_user_logged_in() && $this->route_classifier->is_permission_error_passthrough( $route );
388 $is_public_auth_route = $this->route_classifier->is_public( $route );
389 $is_printer_token_route = $this->route_classifier->is_printer_token( $route );
390 $is_sync_admin_route = is_user_logged_in() && current_user_can( 'manage_woocommerce' ) && $this->route_classifier->is_admin_op( $route );
391
392 if ( ! $is_public_auth_route && ! $has_route_specific_permission_error && ! $is_printer_token_route && ! $is_sync_admin_route ) {
393 if ( ! current_user_can( 'access_woocommerce_pos' ) ) {
394 if ( ! is_user_logged_in() ) {
395 $data = array( 'status' => 401 );
396 if ( null !== $this->auth_error ) {
397 $data['reason'] = $this->auth_error->get_error_code();
398 if ( 'woocommerce_pos_auth_token_expired' !== $data['reason'] ) {
399 Logger::warning(
400 'POS request refused: ' . $data['reason'] . '' . $this->auth_error->get_error_message(),
401 array(
402 'route' => $route,
403 'method' => $request->get_method(),
404 'reason' => $data['reason'],
405 )
406 );
407 }
408 }
409
410 return new \WP_Error(
411 'woocommerce_pos_rest_unauthorized',
412 __( 'Authentication required.', 'woocommerce-pos' ),
413 $data
414 );
415 }
416
417 return new \WP_Error(
418 'woocommerce_pos_rest_forbidden',
419 __( 'You do not have permission to access the POS.', 'woocommerce-pos' ),
420 array( 'status' => 403 )
421 );
422 }
423 }
424
425 $max_length = 10000;
426
427 // The sync sub-surface speaks its own wire contract (include = raw id
428 // list validated by its controllers); the wcpos_include/exclude rewrite
429 // below is a legacy extended-WC-controller workaround and must not
430 // mangle sync routes.
431 if ( $this->route_classifier->is_rewrite_exempt( $route ) ) {
432 return $result;
433 }
434
435 // Process 'include' parameter.
436 $include = $request->get_param( 'include' );
437 if ( $include ) {
438 $processed_include = $this->shorten_param_array( $include, $max_length );
439 $request->set_param( 'wcpos_include', $processed_include );
440 unset( $request['include'] );
441 }
442
443 // Process 'exclude' parameter.
444 $exclude = $request->get_param( 'exclude' );
445 if ( $exclude ) {
446 $processed_exclude = $this->shorten_param_array( $exclude, $max_length );
447 $request->set_param( 'wcpos_exclude', $processed_exclude );
448 unset( $request['exclude'] );
449 }
450
451 return $result;
452 }
453
454 /**
455 * Add the server pressure bucket to WCPOS REST responses.
456 *
457 * @param mixed $response REST response.
458 * @param WP_REST_Server $server REST server.
459 * @param WP_REST_Request $request REST request.
460 *
461 * @return mixed
462 */
463 public function rest_post_dispatch( $response, $server, $request ) {
464 if ( is_wp_error( $response ) ) {
465 return $response;
466 }
467
468 try {
469 if ( ! $response instanceof WP_HTTP_Response || ! $this->route_classifier->in_wcpos_namespace( $request->get_route() ) ) {
470 return $response;
471 }
472 $pressure_bucket = API\V2\Ping::pressure_bucket();
473 if ( null !== $pressure_bucket ) {
474 $response->header( 'X-WCPOS-Pressure', $pressure_bucket );
475 }
476 } catch ( \Throwable $e ) {
477 return $response;
478 }
479
480 return $response;
481 }
482
483 /**
484 * Filters the REST API dispatch request result.
485 *
486 * @param mixed $dispatch_result Dispatch result, will be used if not empty.
487 * @param WP_REST_Request $request Request used to generate the response.
488 * @param string $route Route matched for the request.
489 * @param array $handler Route handler used for the request.
490 *
491 * @return mixed
492 */
493 public function rest_dispatch_request( $dispatch_result, $request, $route, $handler ) {
494 // Only process mapped WCPOS routes.
495 $controller = $this->registry->controller_for_route( $route );
496 if ( null === $controller ) {
497 return $dispatch_result;
498 }
499
500 /*
501 * POS-specific PHP settings to prevent errors in JSON and float weirdness.
502 *
503 * - error_reporting(0) - Turn off error reporting
504 * - ini_set('display_errors', 0) - Turn off error display
505 * - ini_set('precision', 10) - Set the precision of floating point numbers
506 * - ini_set('serialize_precision', 10) - Set the precision of floating point numbers for serialization
507 *
508 * This is to prevent any PHP errors from being displayed in the response.
509 *
510 * The precision settings are to prevent floating point weirdness, eg: stock_quantity 3.6 becomes 3.6000000000000001
511 */
512 error_reporting( 0 );
513 @ini_set( 'display_errors', '0' ); // phpcs:ignore WordPress.PHP.IniSet.display_errors_Disallowed -- intentionally disabling error display for POS API responses.
514 @ini_set( 'precision', '10' );
515 @ini_set( 'serialize_precision', '10' );
516
517 if ( method_exists( $controller, 'wcpos_dispatch_request' ) ) {
518 return $controller->wcpos_dispatch_request( $dispatch_result, $request, $route, $handler );
519 }
520
521 return $dispatch_result;
522 }
523
524 /**
525 * Some servers have a limit on the number of include/exclude we can use in a request.
526 * Worst thing is there is often no error message, the request returns an empty response.
527 *
528 * For example, WP Engine has a limit of 1024 characters?
529 * https://wpengine.com/support/using-dev-tools/#Long_Queries_in_wp_db
530 *
531 * @TODO - For long queries, I should find a better solution than this.
532 *
533 * @param array|string $param_value The parameter value.
534 * @param int $max_length The maximum length.
535 *
536 * @return array
537 */
538 private function shorten_param_array( $param_value, $max_length ) {
539 $param_array = \is_array( $param_value ) ? $param_value : explode( ',', $param_value );
540 $param_string = implode( ',', $param_array );
541
542 if ( \strlen( $param_string ) > $max_length ) {
543 shuffle( $param_array ); // Shuffle to randomize.
544
545 $new_param_string = '';
546 $random_param_array = array();
547
548 foreach ( $param_array as $id ) {
549 if ( \strlen( $new_param_string . $id ) < $max_length ) {
550 $new_param_string .= $id . ',';
551 $random_param_array[] = $id;
552 } else {
553 break; // Stop when maximum length is reached.
554 }
555 }
556
557 return $random_param_array;
558 }
559
560 return $param_array;
561 }
562
563 /**
564 * Check the Authorization header for a Bearer token.
565 *
566 * @param false|int $user_id User ID if one has been determined, false otherwise.
567 *
568 * @return false|int|\WP_Error
569 */
570 private function authenticate( $user_id ) {
571 // Per-request: never let a previous authentication's verdict describe this one.
572 $this->auth_error = null;
573 $authenticated_user_id = Auth::instance()->authenticate_request();
574
575 if ( is_wp_error( $authenticated_user_id ) ) {
576 $this->auth_error = $authenticated_user_id;
577 return false === $user_id ? $authenticated_user_id : $user_id;
578 }
579
580 return false === $authenticated_user_id ? $user_id : $authenticated_user_id;
581 }
582 }
583