* * @see http://wcpos.com * @package WCPOS\WooCommercePOS */ namespace WCPOS\WooCommercePOS; use WCPOS\WooCommercePOS\Services\Auth; use WCPOS\WooCommercePOS\Services\Client_Signal; use WCPOS\WooCommercePOS\Services\Settings as SettingsService; use WP_HTTP_Response; use WP_REST_Request; use WP_REST_Response; use WP_REST_Server; /** * API class. */ class API { /** * WCPOS REST API namespaces. */ public const ROUTE_NAMESPACES = array( 'wcpos/v1', 'wcpos/v2' ); /** * WCPOS REST API namespaces and endpoints. * * @var array */ protected $controllers = array(); /** * Map of route patterns to controller keys. * Built during register_routes() for use in rest_dispatch_request(). * * @var array */ protected $route_map = array(); /** * Route permission-gate classifier. * * @var API\Route_Classifier */ protected $route_classifier; /** * Flag to check if authentication has been checked. * * @var bool */ protected $is_auth_checked = false; /** * Flag to track whether WCPOS successfully authenticated the current request * via its own Bearer token. Used to suppress errors from third-party JWT * plugins that inspected the same Authorization header but could not validate * a WCPOS-issued token with their own secret. * * @var bool */ protected $authenticated_via_wcpos = false; /** * Constructor. */ public function __construct() { $this->register_routes(); /* * Adds authentication to for JWT bearer tokens * - We run determine_current_user at 20 to allow other plugins to run first */ add_filter( 'determine_current_user', array( $this, 'determine_current_user' ), 20 ); add_filter( 'rest_authentication_errors', array( $this, 'rest_authentication_errors' ), 50, 1 ); // Adds info about the WordPress install. add_filter( 'rest_index', array( $this, 'rest_index' ), 10, 1 ); // These filters allow changes to the WC REST API response. add_filter( 'rest_dispatch_request', array( $this, 'rest_dispatch_request' ), 10, 4 ); add_filter( 'rest_pre_dispatch', array( $this, 'rest_pre_dispatch' ), 10, 3 ); add_filter( 'rest_pre_dispatch', array( $this, 'clear_third_party_jwt_error' ), 50, 3 ); add_filter( 'rest_post_dispatch', array( $this, 'rest_post_dispatch' ), 10, 3 ); } /** * Get the WCPOS REST API namespaces. * * @return string[] REST API namespaces. */ public function get_route_namespaces(): array { /** * Filter the list of namespaces used in the WCPOS REST API. * * This filter is strictly additive: plugins can register additional WCPOS REST * API namespaces, but the core namespaces cannot be removed — the central * permission gate must keep covering every registered core route. Controllers * remain responsible for declaring any special route classifications within * added namespaces. * * @since 1.10.0 * * @param string[] $namespaces REST API namespaces. */ $namespaces = apply_filters( 'woocommerce_pos_rest_namespaces', self::ROUTE_NAMESPACES ); return array_values( array_unique( array_merge( self::ROUTE_NAMESPACES, (array) $namespaces ) ) ); } /** * Register routes for all controllers. */ public function register_routes(): void { $route_namespaces = $this->get_route_namespaces(); $this->route_classifier = new API\Route_Classifier( $route_namespaces ); /** * Filter the list of controller classes used in the WCPOS REST API. * * This filter allows customizing or extending the set of controller classes that handle * REST API routes for the WCPOS. By filtering these controllers, plugins can * modify existing endpoints or add new controllers for additional functionality. * Core legacy controllers use their versioned WCPOS\WooCommercePOS\API\V1 FQCNs. * * @since 1.5.0 * * @param array $controllers Associative array of controller identifiers to their corresponding class names. * - 'auth' => Fully qualified name of the class handling authentication. * - 'settings' => Fully qualified name of the class handling settings. * - 'cashier' => Fully qualified name of the class handling cashier management. * - 'products' => Fully qualified name of the class handling products. * - 'product_variations' => Fully qualified name of the class handling product variations. * - 'orders' => Fully qualified name of the class handling orders. * - 'customers' => Fully qualified name of the class handling customers. * - 'product_tags' => Fully qualified name of the class handling product tags. * - 'product_categories' => Fully qualified name of the class handling product categories. * - 'taxes' => Fully qualified name of the class handling taxes. * - 'shipping_methods' => Fully qualified name of the class handling shipping methods. * - 'tax_classes' => Fully qualified name of the class handling tax classes. * - 'order_statuses' => Fully qualified name of the class handling order statuses. */ $classes = apply_filters( 'woocommerce_pos_rest_api_controllers', array( // WCPOS rest api controllers. 'auth' => API\V1\Auth::class, 'settings' => API\V1\Settings::class, 'cashier' => API\V1\Cashier::class, 'templates' => API\V1\Templates_Controller::class, 'receipts' => API\V1\Receipts_Controller::class, 'print_jobs' => API\V1\Print_Jobs_Controller::class, // TODO: remove this? 'stores' => API\V1\Stores::class, 'extensions' => API\V1\Extensions::class, 'logs' => API\V1\Logs::class, 'payment_gateways' => API\V1\Payment_Gateways::class, 'gateway_bootstrap' => API\V1\Gateway_Bootstrap_Controller::class, 'checkout' => API\V1\Checkout_Controller::class, // extend WC REST API controllers. 'products' => API\V1\Products_Controller::class, 'product_variations' => API\V1\Product_Variations_Controller::class, 'orders' => API\V1\Orders_Controller::class, 'customers' => API\V1\Customers_Controller::class, 'product_tags' => API\V1\Product_Tags_Controller::class, 'product_categories' => API\V1\Product_Categories_Controller::class, 'product_brands' => API\V1\Product_Brands_Controller::class, 'coupons' => API\V1\Coupons_Controller::class, 'taxes' => API\V1\Taxes_Controller::class, 'shipping_methods' => API\V1\Shipping_Methods_Controller::class, 'tax_classes' => API\V1\Tax_Classes_Controller::class, 'order_statuses' => API\V1\Data_Order_Statuses_Controller::class, ) ); /** * Filter the wcpos/v2 service pass-through controllers (additive to the * frozen v1 surface — the legacy data controllers stay v1-only). * * Extensions that replace a v1 service through * `woocommerce_pos_rest_api_controllers` must carry their service onto * the v2 surface here with their own pass-through subclass (override * `$namespace = 'wcpos/v2'`), exactly as core does — the v2 map is not * derived from the v1 map, so a v1 replacement alone leaves the v2 * twin serving core behavior. * * @since 1.10.0 * * @param array $controllers Associative array of v2 service controller class names. */ $v2_classes = apply_filters( 'woocommerce_pos_rest_api_v2_controllers', array( 'ping' => API\V2\Ping::class, 'echo_probe' => API\V2\Echo_Probe::class, 'site' => API\V2\Site::class, 'auth' => API\V2\Auth::class, 'settings' => API\V2\Settings::class, 'cashier' => API\V2\Cashier::class, 'templates' => API\V2\Templates_Controller::class, 'receipts' => API\V2\Receipts_Controller::class, 'print_jobs' => API\V2\Print_Jobs_Controller::class, 'stores' => API\V2\Stores::class, 'extensions' => API\V2\Extensions::class, 'logs' => API\V2\Logs::class, 'payment_gateways' => API\V2\Payment_Gateways::class, 'gateway_bootstrap' => API\V2\Gateway_Bootstrap_Controller::class, 'checkout' => API\V2\Checkout_Controller::class, 'order_email' => API\V2\Order_Email_Controller::class, 'shipping_methods' => API\V2\Shipping_Methods_Controller::class, 'tax_classes' => API\V2\Tax_Classes_Controller::class, 'order_statuses' => API\V2\Data_Order_Statuses_Controller::class, ) ); foreach ( $v2_classes as $key => $class ) { $classes[ 'v2-' . $key ] = $class; } $legacy_classifications = array( 'auth' => array( 'public' => array( '/wcpos/v1/auth/test', '/wcpos/v1/auth/refresh' ) ), 'print_jobs' => array( 'printer_token' => array( '/wcpos/v1/print-jobs/cloudprnt', '/wcpos/v1/print-jobs/epson-sdp' ) ), 'receipts' => array( 'permission_error_passthrough' => array( '/wcpos/v1/receipts/' ) ), ); foreach ( $classes as $key => $class ) { if ( class_exists( $class ) ) { $this->controllers[ $key ] = new $class(); $this->controllers[ $key ]->register_routes(); if ( method_exists( $this->controllers[ $key ], 'wcpos_route_classifications' ) ) { $this->route_classifier->merge( $this->controllers[ $key ]->wcpos_route_classifications() ); } elseif ( isset( $legacy_classifications[ $key ] ) ) { $this->route_classifier->merge( $legacy_classifications[ $key ] ); } } } // Sync classifications are independent of feature-gated route registration. $this->route_classifier->merge( Sync\Api::route_classifications() ); // Build route map for use in rest_dispatch_request(). $rest_server = rest_get_server(); foreach ( $route_namespaces as $route_namespace ) { $all_routes = $rest_server->get_routes( $route_namespace ); foreach ( $all_routes as $route_pattern => $route_handlers ) { foreach ( $route_handlers as $route_handler ) { $callback = $route_handler['callback'] ?? null; // Extract the controller object from the callback. $controller_obj = null; if ( \is_array( $callback ) && isset( $callback[0] ) && \is_object( $callback[0] ) ) { $controller_obj = $callback[0]; } elseif ( $callback instanceof \Closure ) { // WC 10.5+ RestApiCache wraps callbacks in closures. // Use reflection to extract the bound $this. $ref = new \ReflectionFunction( $callback ); $controller_obj = $ref->getClosureThis(); } if ( ! $controller_obj ) { continue; } // Find which controller key this object belongs to. foreach ( $this->controllers as $key => $registered_controller ) { if ( $controller_obj === $registered_controller ) { $this->route_map[ $route_pattern ] = $key; break; } } } } } } /** * Check request for any login tokens. * * Runs at priority 20, after other plugins (e.g. third-party JWT plugins at * priority 10) have had a chance to authenticate the user. If another plugin * already returned a valid user ID, we trust it. Otherwise we attempt our own * WCPOS Bearer-token authentication. * * Note: some JWT plugins pass a WP_Error through this filter when they fail to * validate a Bearer token. We treat that the same as false so we can still * authenticate the request with our own JWT. * * Note: this filter may not be called at all when WordPress has already cached * the current user (WooCommerce issue #26847). The rest_authentication_errors * fallback below handles that scenario. * * @param false|int|\WP_Error $user_id User ID if one has been determined, false otherwise. * * @return false|int */ public function determine_current_user( $user_id ) { $this->is_auth_checked = true; // Trust a valid user ID set by another plugin (e.g. JWT plugin with its own token). // Treat a WP_Error the same as false — another plugin rejected its own token, // but we should still attempt authentication with our Bearer token. if ( ! empty( $user_id ) && ! is_wp_error( $user_id ) ) { return $user_id; } $result = $this->authenticate( false ); if ( $result && ! is_wp_error( $result ) ) { $this->authenticated_via_wcpos = true; return $result; } // If neither we nor another plugin authenticated the user, return false // (not authenticated) rather than a WP_Error from $user_id. WordPress core // expects determine_current_user to return false|int, not WP_Error. // The JWT plugin's error will surface via rest_authentication_errors instead. return is_wp_error( $user_id ) ? false : $user_id; } /** * Handles two distinct failure modes: * * 1. WooCommerce issue #26847: determine_current_user may not be called when * WordPress has already cached the current user. We attempt auth here as a * fallback. * * 2. JWT plugin conflict: a third-party JWT plugin sees our Bearer token, fails * to validate it with its own secret, and returns a WP_Error via * rest_authentication_errors at priority 10. We run at priority 50 and attempt * our own Bearer-token validation. If it succeeds, we clear the stale error — * our authentication wins. (jwt-authentication-for-wp-rest-api surfaces its * error through rest_pre_dispatch instead; see clear_third_party_jwt_error().) * * @param mixed $errors Authentication errors. * * @return mixed */ public function rest_authentication_errors( $errors ) { // If there is already an error from a previous filter (e.g. a JWT plugin that // rejected our Bearer token), attempt WCPOS authentication before passing it // through. This covers the case where determine_current_user was skipped // (WC #26847) or where the JWT plugin ran at a higher priority. if ( ! empty( $errors ) ) { // Only clear errors that originate from JWT authentication plugins. Errors // from other mechanisms (maintenance locks, IP restrictions, etc.) should // be passed through even when the WCPOS Bearer token is valid. if ( $this->is_third_party_jwt_error( $errors ) && $this->ensure_authenticated_via_wcpos() ) { return null; } return $errors; } // check if determine_current_user has been called. if ( ! $this->is_auth_checked && $this->ensure_authenticated_via_wcpos() ) { // Authentication hadn't occurred during `determine_current_user`, but our token is valid. return true; } return $errors; } /** * Clear a third-party JWT plugin's stale error from the dispatch result. * * The plugin jwt-authentication-for-wp-rest-api (verified at 1.5.0) validates every * Bearer token in determine_current_user (priority 10) with its own secret. Ours fails, * so it stores a `jwt_auth_invalid_token` WP_Error and returns the user untouched; * our priority-20 filter then authenticates the request. The plugin later returns * that stored error from rest_pre_dispatch (priority 10, registered at * plugins_loaded), which replaces the dispatch result with a 403. * * Priority 50: after the plugin's callback, and after our own priority-10 * permission gate, whose `woocommerce_pos_rest_*` errors must pass through untouched. * * Unlike rest_authentication_errors(), this never switches the current user: the * priority-10 gate and the core-order audit guard have already judged the user in * scope, so the error is cleared only when our token resolves to that same user. * * @param mixed $result Dispatch result, or null to not hijack the request. * @param WP_REST_Server $server Server instance. * @param WP_REST_Request $request Request used to generate the response. * * @return mixed */ public function clear_third_party_jwt_error( $result, $server, $request ) { if ( ! $this->is_third_party_jwt_error( $result ) ) { return $result; } if ( ! $this->authenticated_via_wcpos ) { $user_id = $this->authenticate( false ); if ( $user_id && ! is_wp_error( $user_id ) && get_current_user_id() === (int) $user_id ) { $this->authenticated_via_wcpos = true; } } return $this->authenticated_via_wcpos ? null : $result; } /** * Whether a value is a WP_Error raised by a third-party JWT plugin (`jwt_auth_*`). * * @param mixed $maybe_error Value to inspect. * * @return bool */ private function is_third_party_jwt_error( $maybe_error ): bool { return is_wp_error( $maybe_error ) && 0 === strpos( $maybe_error->get_error_code(), 'jwt_auth_' ); } /** * Authenticate the request with its WCPOS Bearer token if that hasn't happened yet. * * @return bool True when the request is authenticated via a WCPOS-issued token. */ private function ensure_authenticated_via_wcpos(): bool { if ( ! $this->authenticated_via_wcpos ) { $user_id = $this->authenticate( false ); if ( $user_id && ! is_wp_error( $user_id ) ) { wp_set_current_user( $user_id ); $this->authenticated_via_wcpos = true; } } return $this->authenticated_via_wcpos; } /** * Extract the Authorization Bearer token from the request. * * @return false|string */ public function get_auth_header() { return Auth::instance()->get_auth_header(); } /** * Adds info to the WP REST API index response. * - UUID * - Version Info. * * @param WP_REST_Response $response Response data. * * @return WP_REST_Response */ public function rest_index( WP_REST_Response $response ): WP_REST_Response { $uuid = wcpos_get_site_uuid(); $response->data['uuid'] = $uuid; $response->data['wp_version'] = get_bloginfo( 'version' ); $response->data['wc_version'] = WC()->version; $response->data['wcpos_version'] = VERSION; $response->data['use_jwt_as_param'] = SettingsService::instance()->use_jwt_as_param_enabled(); // Add WCPOS authentication endpoint to the response. $response->data['authentication']['wcpos'] = array( 'endpoints' => array( 'authorization' => Template_Router::get_auth_url(), ), ); /** * Remove the routes from the response. * * Some wordpress sites have a huge number of routes, like 2MB of data. It shouldn;t matter, but it seems * to cause issues with the desktop application sometimes. We don't use the routes at the moment, so we * can remove them from the response. */ $data = $response->get_data(); unset( $data['routes'] ); $response->set_data( $data ); return $response; } /** * Filters the pre-calculated result of a REST API dispatch request. * * Allow hijacking the request before dispatching by returning a non-empty. The returned value * will be used to serve the request instead. * * @param mixed $result Response to replace the requested version with. Can be anything * a normal endpoint can return, or null to not hijack the request. * @param WP_REST_Server $server Server instance. * @param WP_REST_Request $request Request used to generate the response. * * @return mixed */ public function rest_pre_dispatch( $result, $server, $request ) { if ( ! $this->route_classifier->in_wcpos_namespace( $request->get_route() ) ) { return $result; } // Marker-gated on purpose (query var or header, NOT the rest_route arm, // which matches this namespace by construction): every real POS client, // old or new, carries the marker, while unmarked scanner traffic would // otherwise inflate the `channel: none` tail this telemetry exists to // measure (free#1752). The echo and auth lanes are excluded for the same // reason: they are the gate's carve-outs, and a protocol-2 client's // connect-time probes deliberately carry no signal — counting them would // stamp every modern client with a daily false `none` row. if ( 0 === stripos( $request->get_route(), '/wcpos/v2/' ) && 1 !== preg_match( '#^/wcpos/v2/(?:echo$|auth(?:/|$))#i', $request->get_route() ) && ( wcpos_request( 'query_var' ) || wcpos_request( 'header' ) ) ) { try { Client_Signal::record( $request ); } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Telemetry failures are deliberately ignored. // Telemetry must never interrupt a POS request. } } // Latch the till's store scope for the whole request (pro#425). Set // unconditionally — including to null — so a scope never leaks from one // dispatch into the next. Inner `wc/v3` forwards do not reach this line // (they are outside the WCPOS namespace), which is exactly right: the // OUTER request owns the scope and stamps it onto the inner ones. \WCPOS\WooCommercePOS\Sync\Store_Scope::set_current( \WCPOS\WooCommercePOS\Sync\Store_Scope::resolve( $request ) ); // CORS preflights carry no credentials (browsers strip Authorization from OPTIONS), // so the permission gate must never answer them with 401 — a non-2xx preflight blocks // every cross-origin standalone client from the entire namespace. WP core serves // OPTIONS with route metadata and Rest_Cors::rest_pre_serve_request adds the CORS headers. if ( 'OPTIONS' === $request->get_method() ) { return $result; } // Baseline permission gate: POS endpoints require access_woocommerce_pos; the three // sync admin operations instead use their route-level manage_woocommerce check. // Exempt public auth, printer-token polling, and authenticated receipt denials that need // the receipt-specific error code. $route = $request->get_route(); $has_route_specific_permission_error = is_user_logged_in() && $this->route_classifier->is_permission_error_passthrough( $route ); $is_public_auth_route = $this->route_classifier->is_public( $route ); $is_printer_token_route = $this->route_classifier->is_printer_token( $route ); $is_sync_admin_route = is_user_logged_in() && current_user_can( 'manage_woocommerce' ) && $this->route_classifier->is_admin_op( $route ); if ( ! $is_public_auth_route && ! $has_route_specific_permission_error && ! $is_printer_token_route && ! $is_sync_admin_route ) { if ( ! current_user_can( 'access_woocommerce_pos' ) ) { if ( ! is_user_logged_in() ) { return new \WP_Error( 'woocommerce_pos_rest_unauthorized', __( 'Authentication required.', 'woocommerce-pos' ), array( 'status' => 401 ) ); } return new \WP_Error( 'woocommerce_pos_rest_forbidden', __( 'You do not have permission to access the POS.', 'woocommerce-pos' ), array( 'status' => 403 ) ); } } $max_length = 10000; // The sync sub-surface speaks its own wire contract (include = raw id // list validated by its controllers); the wcpos_include/exclude rewrite // below is a legacy extended-WC-controller workaround and must not // mangle sync routes. if ( $this->route_classifier->is_rewrite_exempt( $route ) ) { return $result; } // Process 'include' parameter. $include = $request->get_param( 'include' ); if ( $include ) { $processed_include = $this->shorten_param_array( $include, $max_length ); $request->set_param( 'wcpos_include', $processed_include ); unset( $request['include'] ); } // Process 'exclude' parameter. $exclude = $request->get_param( 'exclude' ); if ( $exclude ) { $processed_exclude = $this->shorten_param_array( $exclude, $max_length ); $request->set_param( 'wcpos_exclude', $processed_exclude ); unset( $request['exclude'] ); } return $result; } /** * Add the server pressure bucket to WCPOS REST responses. * * @param mixed $response REST response. * @param WP_REST_Server $server REST server. * @param WP_REST_Request $request REST request. * * @return mixed */ public function rest_post_dispatch( $response, $server, $request ) { if ( is_wp_error( $response ) ) { return $response; } try { if ( ! $response instanceof WP_HTTP_Response || ! $this->route_classifier->in_wcpos_namespace( $request->get_route() ) ) { return $response; } $pressure_bucket = API\V2\Ping::pressure_bucket(); if ( null !== $pressure_bucket ) { $response->header( 'X-WCPOS-Pressure', $pressure_bucket ); } } catch ( \Throwable $e ) { return $response; } return $response; } /** * Filters the REST API dispatch request result. * * @param mixed $dispatch_result Dispatch result, will be used if not empty. * @param WP_REST_Request $request Request used to generate the response. * @param string $route Route matched for the request. * @param array $handler Route handler used for the request. * * @return mixed */ public function rest_dispatch_request( $dispatch_result, $request, $route, $handler ) { // Only process mapped WCPOS routes. if ( ! isset( $this->route_map[ $route ] ) ) { return $dispatch_result; } /* * POS-specific PHP settings to prevent errors in JSON and float weirdness. * * - error_reporting(0) - Turn off error reporting * - ini_set('display_errors', 0) - Turn off error display * - ini_set('precision', 10) - Set the precision of floating point numbers * - ini_set('serialize_precision', 10) - Set the precision of floating point numbers for serialization * * This is to prevent any PHP errors from being displayed in the response. * * The precision settings are to prevent floating point weirdness, eg: stock_quantity 3.6 becomes 3.6000000000000001 */ error_reporting( 0 ); @ini_set( 'display_errors', '0' ); // phpcs:ignore WordPress.PHP.IniSet.display_errors_Disallowed -- intentionally disabling error display for POS API responses. @ini_set( 'precision', '10' ); @ini_set( 'serialize_precision', '10' ); $key = $this->route_map[ $route ]; $controller = $this->controllers[ $key ] ?? null; if ( $controller && method_exists( $controller, 'wcpos_dispatch_request' ) ) { return $controller->wcpos_dispatch_request( $dispatch_result, $request, $route, $handler ); } return $dispatch_result; } /** * Some servers have a limit on the number of include/exclude we can use in a request. * Worst thing is there is often no error message, the request returns an empty response. * * For example, WP Engine has a limit of 1024 characters? * https://wpengine.com/support/using-dev-tools/#Long_Queries_in_wp_db * * @TODO - For long queries, I should find a better solution than this. * * @param array|string $param_value The parameter value. * @param int $max_length The maximum length. * * @return array */ private function shorten_param_array( $param_value, $max_length ) { $param_array = \is_array( $param_value ) ? $param_value : explode( ',', $param_value ); $param_string = implode( ',', $param_array ); if ( \strlen( $param_string ) > $max_length ) { shuffle( $param_array ); // Shuffle to randomize. $new_param_string = ''; $random_param_array = array(); foreach ( $param_array as $id ) { if ( \strlen( $new_param_string . $id ) < $max_length ) { $new_param_string .= $id . ','; $random_param_array[] = $id; } else { break; // Stop when maximum length is reached. } } return $random_param_array; } return $param_array; } /** * Check the Authorization header for a Bearer token. * * @param false|int $user_id User ID if one has been determined, false otherwise. * * @return false|int|\WP_Error */ private function authenticate( $user_id ) { $authenticated_user_id = Auth::instance()->authenticate_request(); if ( is_wp_error( $authenticated_user_id ) ) { return false === $user_id ? $authenticated_user_id : $user_id; } return false === $authenticated_user_id ? $user_id : $authenticated_user_id; } }