| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | <?php |
| 2 | 2 | /** |
| 3 | - * WCPOS REST API Class, ie: /wcpos/v1/ endpoints. | |
| 3 | + * WCPOS REST API class. | |
| 4 | 4 | * |
| 5 | 5 | * @author Paul Kilmurray <paul@kilbot.com> |
| 6 | 6 | * |
| 7 | 7 | * @see http://wcpos.com |
| @@ -9,10 +9,12 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | namespace WCPOS\WooCommercePOS; |
| 12 | 12 | |
| 13 | -use Ramsey\Uuid\Uuid; | |
| 13 | +use WCPOS\WooCommercePOS\API\Controller_Registry; | |
| 14 | 14 | use WCPOS\WooCommercePOS\Services\Auth; |
| 15 | +use WCPOS\WooCommercePOS\Services\Client_Signal; | |
| 16 | +use WCPOS\WooCommercePOS\Services\Settings as SettingsService; | |
| 15 | 17 | use WP_HTTP_Response; |
| 16 | 18 | use WP_REST_Request; |
| 17 | 19 | use WP_REST_Response; |
| 18 | 20 | use WP_REST_Server; |
| @@ -21,21 +23,25 @@ | ||
| 21 | 23 | * API class. |
| 22 | 24 | */ |
| 23 | 25 | class API { |
| 24 | 26 | /** |
| 25 | - * WCPOS REST API namespaces and endpoints. | |
| 27 | + * WCPOS REST API namespaces. | |
| 28 | + */ | |
| 29 | + public const ROUTE_NAMESPACES = array( 'wcpos/v1', 'wcpos/v2' ); | |
| 30 | + | |
| 31 | + /** | |
| 32 | + * Controller instances and route attribution. | |
| 26 | 33 | * |
| 27 | - * @var array | |
| 34 | + * @var Controller_Registry | |
| 28 | 35 | */ |
| 29 | - protected $controllers = array(); | |
| 36 | + protected Controller_Registry $registry; | |
| 30 | 37 | |
| 31 | 38 | /** |
| 32 | - * Map of route patterns to controller keys. | |
| 33 | - * Built during register_routes() for use in rest_dispatch_request(). | |
| 39 | + * Route permission-gate classifier. | |
| 34 | 40 | * |
| 35 | - * @var array<string, string> | |
| 41 | + * @var API\Route_Classifier | |
| 36 | 42 | */ |
| 37 | - protected $route_map = array(); | |
| 43 | + protected $route_classifier; | |
| 38 | 44 | |
| 39 | 45 | /** |
| 40 | 46 | * Flag to check if authentication has been checked. |
| 41 | 47 | * |
| @@ -43,8 +49,15 @@ | ||
| 43 | 49 | */ |
| 44 | 50 | protected $is_auth_checked = false; |
| 45 | 51 | |
| 46 | 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 | + /** | |
| 47 | 60 | * Flag to track whether WCPOS successfully authenticated the current request |
| 48 | 61 | * via its own Bearer token. Used to suppress errors from third-party JWT |
| 49 | 62 | * plugins that inspected the same Authorization header but could not validate |
| 50 | 63 | * a WCPOS-issued token with their own secret. |
| @@ -58,12 +71,8 @@ | ||
| 58 | 71 | */ |
| 59 | 72 | public function __construct() { |
| 60 | 73 | $this->register_routes(); |
| 61 | 74 | |
| 62 | - // Allows requests from WCPOS Desktop and Mobile Apps. | |
| 63 | - add_filter( 'rest_allowed_cors_headers', array( $this, 'rest_allowed_cors_headers' ), 10, 1 ); | |
| 64 | - add_filter( 'rest_pre_serve_request', array( $this, 'rest_pre_serve_request' ), 10, 4 ); | |
| 65 | - | |
| 66 | 75 | /* |
| 67 | 76 | * Adds authentication to for JWT bearer tokens |
| 68 | 77 | * - We run determine_current_user at 20 to allow other plugins to run first |
| 69 | 78 | */ |
| @@ -75,146 +84,48 @@ | ||
| 75 | 84 | |
| 76 | 85 | // These filters allow changes to the WC REST API response. |
| 77 | 86 | add_filter( 'rest_dispatch_request', array( $this, 'rest_dispatch_request' ), 10, 4 ); |
| 78 | 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 ); | |
| 79 | 90 | } |
| 80 | 91 | |
| 81 | 92 | /** |
| 82 | - * Register routes for all controllers. | |
| 93 | + * Get the WCPOS REST API namespaces. | |
| 94 | + * | |
| 95 | + * @return string[] REST API namespaces. | |
| 83 | 96 | */ |
| 84 | - public function register_routes(): void { | |
| 97 | + public function get_route_namespaces(): array { | |
| 85 | 98 | /** |
| 86 | - * Filter the list of controller classes used in the WCPOS REST API. | |
| 99 | + * Filter the list of namespaces used in the WCPOS REST API. | |
| 87 | 100 | * |
| 88 | - * This filter allows customizing or extending the set of controller classes that handle | |
| 89 | - * REST API routes for the WCPOS. By filtering these controllers, plugins can | |
| 90 | - * modify existing endpoints or add new controllers for additional functionality. | |
| 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. | |
| 91 | 106 | * |
| 92 | - * @since 1.5.0 | |
| 107 | + * @since 1.10.0 | |
| 93 | 108 | * |
| 94 | - * @param array $controllers Associative array of controller identifiers to their corresponding class names. | |
| 95 | - * - 'auth' => Fully qualified name of the class handling authentication. | |
| 96 | - * - 'settings' => Fully qualified name of the class handling settings. | |
| 97 | - * - 'cashier' => Fully qualified name of the class handling cashier management. | |
| 98 | - * - 'products' => Fully qualified name of the class handling products. | |
| 99 | - * - 'product_variations' => Fully qualified name of the class handling product variations. | |
| 100 | - * - 'orders' => Fully qualified name of the class handling orders. | |
| 101 | - * - 'customers' => Fully qualified name of the class handling customers. | |
| 102 | - * - 'product_tags' => Fully qualified name of the class handling product tags. | |
| 103 | - * - 'product_categories' => Fully qualified name of the class handling product categories. | |
| 104 | - * - 'taxes' => Fully qualified name of the class handling taxes. | |
| 105 | - * - 'shipping_methods' => Fully qualified name of the class handling shipping methods. | |
| 106 | - * - 'tax_classes' => Fully qualified name of the class handling tax classes. | |
| 107 | - * - 'order_statuses' => Fully qualified name of the class handling order statuses. | |
| 109 | + * @param string[] $namespaces REST API namespaces. | |
| 108 | 110 | */ |
| 109 | - $classes = apply_filters( | |
| 110 | - 'woocommerce_pos_rest_api_controllers', | |
| 111 | - array( | |
| 112 | - // WCPOS rest api controllers. | |
| 113 | - 'auth' => API\Auth::class, | |
| 114 | - 'settings' => API\Settings::class, | |
| 115 | - 'cashier' => API\Cashier::class, | |
| 116 | - 'templates' => API\Templates_Controller::class, | |
| 117 | - 'receipts' => API\Receipts_Controller::class, | |
| 118 | - 'print_jobs' => API\Print_Jobs_Controller::class, | |
| 111 | + $namespaces = apply_filters( 'woocommerce_pos_rest_namespaces', self::ROUTE_NAMESPACES ); | |
| 119 | 112 | |
| 120 | - // TODO: remove this? | |
| 121 | - 'stores' => API\Stores::class, | |
| 122 | - 'extensions' => API\Extensions::class, | |
| 123 | - 'logs' => API\Logs::class, | |
| 124 | - 'payment_gateways' => API\Payment_Gateways::class, | |
| 125 | - 'gateway_bootstrap' => API\Gateway_Bootstrap_Controller::class, | |
| 126 | - 'checkout' => API\Checkout_Controller::class, | |
| 127 | - | |
| 128 | - // extend WC REST API controllers. | |
| 129 | - 'products' => API\Products_Controller::class, | |
| 130 | - 'product_variations' => API\Product_Variations_Controller::class, | |
| 131 | - 'orders' => API\Orders_Controller::class, | |
| 132 | - 'customers' => API\Customers_Controller::class, | |
| 133 | - 'product_tags' => API\Product_Tags_Controller::class, | |
| 134 | - 'product_categories' => API\Product_Categories_Controller::class, | |
| 135 | - 'product_brands' => API\Product_Brands_Controller::class, | |
| 136 | - 'coupons' => API\Coupons_Controller::class, | |
| 137 | - 'taxes' => API\Taxes_Controller::class, | |
| 138 | - 'shipping_methods' => API\Shipping_Methods_Controller::class, | |
| 139 | - 'tax_classes' => API\Tax_Classes_Controller::class, | |
| 140 | - 'order_statuses' => API\Data_Order_Statuses_Controller::class, | |
| 141 | - ) | |
| 142 | - ); | |
| 143 | - | |
| 144 | - foreach ( $classes as $key => $class ) { | |
| 145 | - if ( class_exists( $class ) ) { | |
| 146 | - $this->controllers[ $key ] = new $class(); | |
| 147 | - $this->controllers[ $key ]->register_routes(); | |
| 148 | - } | |
| 149 | - } | |
| 150 | - | |
| 151 | - // Build route map for use in rest_dispatch_request(). | |
| 152 | - $rest_server = rest_get_server(); | |
| 153 | - $all_routes = $rest_server->get_routes( 'wcpos/v1' ); | |
| 154 | - | |
| 155 | - foreach ( $all_routes as $route_pattern => $route_handlers ) { | |
| 156 | - foreach ( $route_handlers as $route_handler ) { | |
| 157 | - $callback = $route_handler['callback'] ?? null; | |
| 158 | - | |
| 159 | - // Extract the controller object from the callback. | |
| 160 | - $controller_obj = null; | |
| 161 | - if ( \is_array( $callback ) && isset( $callback[0] ) && \is_object( $callback[0] ) ) { | |
| 162 | - $controller_obj = $callback[0]; | |
| 163 | - } elseif ( $callback instanceof \Closure ) { | |
| 164 | - // WC 10.5+ RestApiCache wraps callbacks in closures. | |
| 165 | - // Use reflection to extract the bound $this. | |
| 166 | - $ref = new \ReflectionFunction( $callback ); | |
| 167 | - $controller_obj = $ref->getClosureThis(); | |
| 168 | - } | |
| 169 | - | |
| 170 | - if ( ! $controller_obj ) { | |
| 171 | - continue; | |
| 172 | - } | |
| 173 | - | |
| 174 | - // Find which controller key this object belongs to. | |
| 175 | - foreach ( $this->controllers as $key => $registered_controller ) { | |
| 176 | - if ( $controller_obj === $registered_controller ) { | |
| 177 | - $this->route_map[ $route_pattern ] = $key; | |
| 178 | - break; | |
| 179 | - } | |
| 180 | - } | |
| 181 | - } | |
| 182 | - } | |
| 113 | + return array_values( array_unique( array_merge( self::ROUTE_NAMESPACES, (array) $namespaces ) ) ); | |
| 183 | 114 | } |
| 184 | 115 | |
| 185 | 116 | /** |
| 186 | - * Add CORS headers to the REST API response. | |
| 187 | - * | |
| 188 | - * @param string[] $allow_headers The list of request headers to allow. | |
| 189 | - * | |
| 190 | - * @return string[] $allow_headers | |
| 117 | + * Register routes for all controllers. | |
| 191 | 118 | */ |
| 192 | - public function rest_allowed_cors_headers( array $allow_headers ): array { | |
| 193 | - $allow_headers[] = 'X-WCPOS'; | |
| 194 | - $allow_headers[] = 'X-HTTP-Method-Override'; | |
| 195 | - $allow_headers[] = 'X-WCPOS-Idempotency-Key'; | |
| 119 | + public function register_routes(): void { | |
| 120 | + $route_namespaces = $this->get_route_namespaces(); | |
| 121 | + $this->route_classifier = new API\Route_Classifier( $route_namespaces ); | |
| 196 | 122 | |
| 197 | - return $allow_headers; | |
| 198 | - } | |
| 123 | + $this->registry = new Controller_Registry(); | |
| 124 | + $this->registry->register( $this->route_classifier ); | |
| 199 | 125 | |
| 200 | - /** | |
| 201 | - * Add Access Control Allow Headers for POS app. | |
| 202 | - * | |
| 203 | - * NOTE: I have seen this filter called with NULL for $served, it should be a boolean. | |
| 204 | - * | |
| 205 | - * @param mixed $served Whether the request has already been served. | |
| 206 | - * Default false. | |
| 207 | - * @param WP_HTTP_Response $result Result to send to the client. Usually a `WP_REST_Response`. | |
| 208 | - * @param WP_REST_Request $request Request used to generate the response. | |
| 209 | - * @param WP_REST_Server $server Server instance. | |
| 210 | - * | |
| 211 | - * @return bool $served | |
| 212 | - */ | |
| 213 | - public function rest_pre_serve_request( $served, WP_HTTP_Response $result, WP_REST_Request $request, WP_REST_Server $server ) { | |
| 214 | - $server->send_header( 'Access-Control-Allow-Origin', '*' ); | |
| 215 | - | |
| 216 | - return $served; | |
| 126 | + // Sync classifications are independent of feature-gated route registration. | |
| 127 | + $this->route_classifier->merge( Sync\Api::route_classifications() ); | |
| 217 | 128 | } |
| 218 | 129 | |
| 219 | 130 | /** |
| 220 | 131 | * Check request for any login tokens. |
| @@ -265,13 +176,14 @@ | ||
| 265 | 176 | * 1. WooCommerce issue #26847: determine_current_user may not be called when |
| 266 | 177 | * WordPress has already cached the current user. We attempt auth here as a |
| 267 | 178 | * fallback. |
| 268 | 179 | * |
| 269 | - * 2. JWT plugin conflict: a third-party JWT plugin (e.g. jwt-authentication-for-wp-rest-api) | |
| 270 | - * sees our Bearer token, fails to validate it with its own secret, and returns | |
| 271 | - * a WP_Error via rest_authentication_errors at priority 10. We run at priority 50 | |
| 272 | - * and attempt our own Bearer-token validation. If it succeeds, we clear the | |
| 273 | - * stale error — our authentication wins. | |
| 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().) | |
| 274 | 186 | * |
| 275 | 187 | * @param mixed $errors Authentication errors. |
| 276 | 188 | * |
| 277 | 189 | * @return mixed |
| @@ -284,19 +196,9 @@ | ||
| 284 | 196 | if ( ! empty( $errors ) ) { |
| 285 | 197 | // Only clear errors that originate from JWT authentication plugins. Errors |
| 286 | 198 | // from other mechanisms (maintenance locks, IP restrictions, etc.) should |
| 287 | 199 | // be passed through even when the WCPOS Bearer token is valid. |
| 288 | - $is_jwt_plugin_error = is_wp_error( $errors ) && 0 === strpos( $errors->get_error_code(), 'jwt_auth_' ); | |
| 289 | - | |
| 290 | - if ( $is_jwt_plugin_error && ! $this->authenticated_via_wcpos ) { | |
| 291 | - $user_id = $this->authenticate( false ); | |
| 292 | - if ( $user_id && ! is_wp_error( $user_id ) ) { | |
| 293 | - wp_set_current_user( $user_id ); | |
| 294 | - $this->authenticated_via_wcpos = true; | |
| 295 | - } | |
| 296 | - } | |
| 297 | - | |
| 298 | - if ( $this->authenticated_via_wcpos && $is_jwt_plugin_error ) { | |
| 200 | + if ( $this->is_third_party_jwt_error( $errors ) && $this->ensure_authenticated_via_wcpos() ) { | |
| 299 | 201 | return null; |
| 300 | 202 | } |
| 301 | 203 | |
| 302 | 204 | return $errors; |
| @@ -302,20 +204,80 @@ | ||
| 302 | 204 | return $errors; |
| 303 | 205 | } |
| 304 | 206 | |
| 305 | 207 | // check if determine_current_user has been called. |
| 306 | - if ( ! $this->is_auth_checked ) { | |
| 307 | - // Authentication hasn't occurred during `determine_current_user`, so check auth. | |
| 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 ) { | |
| 308 | 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 ); | |
| 309 | 273 | if ( $user_id && ! is_wp_error( $user_id ) ) { |
| 310 | 274 | wp_set_current_user( $user_id ); |
| 311 | 275 | $this->authenticated_via_wcpos = true; |
| 312 | - | |
| 313 | - return true; | |
| 314 | 276 | } |
| 315 | 277 | } |
| 316 | 278 | |
| 317 | - return $errors; | |
| 279 | + return $this->authenticated_via_wcpos; | |
| 318 | 280 | } |
| 319 | 281 | |
| 320 | 282 | /** |
| 321 | 283 | * Extract the Authorization Bearer token from the request. |
| @@ -322,26 +284,9 @@ | ||
| 322 | 284 | * |
| 323 | 285 | * @return false|string |
| 324 | 286 | */ |
| 325 | 287 | public function get_auth_header() { |
| 326 | - // Check if HTTP_AUTHORIZATION is set and not empty | |
| 327 | - // (htaccess SetEnvIf can set an empty value when no header is present). | |
| 328 | - if ( ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) ) { | |
| 329 | - return sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) ); | |
| 330 | - } | |
| 331 | - | |
| 332 | - // Check for alternative header in $_SERVER. | |
| 333 | - if ( ! empty( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) { | |
| 334 | - return sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ); | |
| 335 | - } | |
| 336 | - | |
| 337 | - // Check for authorization param in URL ($_GET). | |
| 338 | - if ( ! empty( $_GET['authorization'] ) ) { | |
| 339 | - return sanitize_text_field( wp_unslash( $_GET['authorization'] ) ); | |
| 340 | - } | |
| 341 | - | |
| 342 | - // Return false if none of the variables are set. | |
| 343 | - return false; | |
| 288 | + return Auth::instance()->get_auth_header(); | |
| 344 | 289 | } |
| 345 | 290 | |
| 346 | 291 | /** |
| 347 | 292 | * Adds info to the WP REST API index response. |
| @@ -352,18 +297,14 @@ | ||
| 352 | 297 | * |
| 353 | 298 | * @return WP_REST_Response |
| 354 | 299 | */ |
| 355 | 300 | public function rest_index( WP_REST_Response $response ): WP_REST_Response { |
| 356 | - $uuid = get_option( 'woocommerce_pos_uuid' ); | |
| 357 | - if ( ! $uuid ) { | |
| 358 | - $uuid = Uuid::uuid4()->toString(); | |
| 359 | - update_option( 'woocommerce_pos_uuid', $uuid ); | |
| 360 | - } | |
| 301 | + $uuid = wcpos_get_site_uuid(); | |
| 361 | 302 | $response->data['uuid'] = $uuid; |
| 362 | 303 | $response->data['wp_version'] = get_bloginfo( 'version' ); |
| 363 | 304 | $response->data['wc_version'] = WC()->version; |
| 364 | 305 | $response->data['wcpos_version'] = VERSION; |
| 365 | - $response->data['use_jwt_as_param'] = woocommerce_pos_get_settings( 'tools', 'use_jwt_as_param' ); | |
| 306 | + $response->data['use_jwt_as_param'] = SettingsService::instance()->use_jwt_as_param_enabled(); | |
| 366 | 307 | |
| 367 | 308 | // Add WCPOS authentication endpoint to the response. |
| 368 | 309 | $response->data['authentication']['wcpos'] = array( |
| 369 | 310 | 'endpoints' => array( |
| @@ -398,30 +339,79 @@ | ||
| 398 | 339 | * |
| 399 | 340 | * @return mixed |
| 400 | 341 | */ |
| 401 | 342 | public function rest_pre_dispatch( $result, $server, $request ) { |
| 402 | - if ( strpos( $request->get_route(), '/wcpos/v1/' ) !== 0 ) { | |
| 343 | + if ( ! $this->route_classifier->in_wcpos_namespace( $request->get_route() ) ) { | |
| 403 | 344 | return $result; |
| 404 | 345 | } |
| 405 | 346 | |
| 406 | - // Baseline permission gate: all POS endpoints require access_woocommerce_pos. | |
| 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. | |
| 407 | 384 | // Exempt public auth, printer-token polling, and authenticated receipt denials that need |
| 408 | 385 | // the receipt-specific error code. |
| 409 | 386 | $route = $request->get_route(); |
| 410 | - $has_route_specific_permission_error = is_user_logged_in() && 0 === strpos( $route, '/wcpos/v1/receipts/' ); | |
| 411 | - $is_public_auth_route = \in_array( $route, array( '/wcpos/v1/auth/test', '/wcpos/v1/auth/refresh' ), true ); | |
| 412 | - $is_printer_token_route = \in_array( $route, array( '/wcpos/v1/print-jobs/cloudprnt', '/wcpos/v1/print-jobs/epson-sdp' ), true ) | |
| 413 | - || 0 === strpos( $route, '/wcpos/v1/print-jobs/cloudprnt/' ) | |
| 414 | - || 0 === strpos( $route, '/wcpos/v1/print-jobs/epson-sdp/' ); | |
| 415 | - $is_relay_verification_route = '/wcpos/v1/print-jobs/relay-verification' === $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 ); | |
| 416 | 391 | |
| 417 | - if ( ! $is_public_auth_route && ! $has_route_specific_permission_error && ! $is_printer_token_route && ! $is_relay_verification_route ) { | |
| 392 | + if ( ! $is_public_auth_route && ! $has_route_specific_permission_error && ! $is_printer_token_route && ! $is_sync_admin_route ) { | |
| 418 | 393 | if ( ! current_user_can( 'access_woocommerce_pos' ) ) { |
| 419 | 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 | + | |
| 420 | 410 | return new \WP_Error( |
| 421 | 411 | 'woocommerce_pos_rest_unauthorized', |
| 422 | 412 | __( 'Authentication required.', 'woocommerce-pos' ), |
| 423 | - array( 'status' => 401 ) | |
| 413 | + $data | |
| 424 | 414 | ); |
| 425 | 415 | } |
| 426 | 416 | |
| 427 | 417 | return new \WP_Error( |
| @@ -433,8 +423,16 @@ | ||
| 433 | 423 | } |
| 434 | 424 | |
| 435 | 425 | $max_length = 10000; |
| 436 | 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 | + | |
| 437 | 435 | // Process 'include' parameter. |
| 438 | 436 | $include = $request->get_param( 'include' ); |
| 439 | 437 | if ( $include ) { |
| 440 | 438 | $processed_include = $this->shorten_param_array( $include, $max_length ); |
| @@ -453,8 +451,37 @@ | ||
| 453 | 451 | return $result; |
| 454 | 452 | } |
| 455 | 453 | |
| 456 | 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 | + /** | |
| 457 | 484 | * Filters the REST API dispatch request result. |
| 458 | 485 | * |
| 459 | 486 | * @param mixed $dispatch_result Dispatch result, will be used if not empty. |
| 460 | 487 | * @param WP_REST_Request $request Request used to generate the response. |
| @@ -463,10 +490,11 @@ | ||
| 463 | 490 | * |
| 464 | 491 | * @return mixed |
| 465 | 492 | */ |
| 466 | 493 | public function rest_dispatch_request( $dispatch_result, $request, $route, $handler ) { |
| 467 | - // Only process wcpos/v1 routes. | |
| 468 | - if ( ! isset( $this->route_map[ $route ] ) ) { | |
| 494 | + // Only process mapped WCPOS routes. | |
| 495 | + $controller = $this->registry->controller_for_route( $route ); | |
| 496 | + if ( null === $controller ) { | |
| 469 | 497 | return $dispatch_result; |
| 470 | 498 | } |
| 471 | 499 | |
| 472 | 500 | /* |
| @@ -485,12 +513,9 @@ | ||
| 485 | 513 | @ini_set( 'display_errors', '0' ); // phpcs:ignore WordPress.PHP.IniSet.display_errors_Disallowed -- intentionally disabling error display for POS API responses. |
| 486 | 514 | @ini_set( 'precision', '10' ); |
| 487 | 515 | @ini_set( 'serialize_precision', '10' ); |
| 488 | 516 | |
| 489 | - $key = $this->route_map[ $route ]; | |
| 490 | - $controller = $this->controllers[ $key ] ?? null; | |
| 491 | - | |
| 492 | - if ( $controller && method_exists( $controller, 'wcpos_dispatch_request' ) ) { | |
| 517 | + if ( method_exists( $controller, 'wcpos_dispatch_request' ) ) { | |
| 493 | 518 | return $controller->wcpos_dispatch_request( $dispatch_result, $request, $route, $handler ); |
| 494 | 519 | } |
| 495 | 520 | |
| 496 | 521 | return $dispatch_result; |
| @@ -542,33 +567,16 @@ | ||
| 542 | 567 | * |
| 543 | 568 | * @return false|int|\WP_Error |
| 544 | 569 | */ |
| 545 | 570 | private function authenticate( $user_id ) { |
| 546 | - // check if there is an auth header. | |
| 547 | - $auth_header = $this->get_auth_header(); | |
| 548 | - if ( ! \is_string( $auth_header ) ) { | |
| 549 | - return $user_id; | |
| 550 | - } | |
| 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(); | |
| 551 | 574 | |
| 552 | - // Extract Bearer token from Authorization Header. | |
| 553 | - list($token) = sscanf( $auth_header, 'Bearer %s' ); | |
| 554 | - | |
| 555 | - if ( $token ) { | |
| 556 | - $auth_service = Auth::instance(); | |
| 557 | - $decoded_token = $auth_service->validate_token( $token ); | |
| 558 | - | |
| 559 | - // Check if validate_token returned WP_Error and user_id is null. | |
| 560 | - if ( is_wp_error( $decoded_token ) && false === $user_id ) { | |
| 561 | - return $decoded_token; | |
| 562 | - } | |
| 563 | - | |
| 564 | - // If the token is valid, set the user_id. | |
| 565 | - if ( ! is_wp_error( $decoded_token ) ) { | |
| 566 | - $user_id = $decoded_token->data->user->id; | |
| 567 | - | |
| 568 | - return absint( $user_id ); | |
| 569 | - } | |
| 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; | |
| 570 | 578 | } |
| 571 | 579 | |
| 572 | - return $user_id; | |
| 580 | + return false === $authenticated_user_id ? $user_id : $authenticated_user_id; | |
| 573 | 581 | } |
| 574 | 582 | } |