| 1 |
<?php |
| 2 |
/** |
| 3 |
* Load required classes. |
| 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\Admin\Consent; |
| 14 |
use WCPOS\WooCommercePOS\Admin\Menu; |
| 15 |
use WCPOS\WooCommercePOS\Services\Auth as AuthService; |
| 16 |
use WCPOS\WooCommercePOS\Services\Extensions; |
| 17 |
use WCPOS\WooCommercePOS\Services\Receipt_Snapshot_Store; |
| 18 |
use WCPOS\WooCommercePOS\Services\Settings as SettingsService; |
| 19 |
use WP_HTTP_Response; |
| 20 |
use WP_REST_Request; |
| 21 |
use WP_REST_Server; |
| 22 |
use const DOING_AJAX; |
| 23 |
|
| 24 |
/** |
| 25 |
* Init class. |
| 26 |
*/ |
| 27 |
class Init { |
| 28 |
/** |
| 29 |
* Constructor. |
| 30 |
*/ |
| 31 |
public function __construct() { |
| 32 |
// global helper functions. |
| 33 |
require_once PLUGIN_PATH . 'includes/wcpos-functions.php'; |
| 34 |
require_once PLUGIN_PATH . 'includes/wcpos-store-functions.php'; |
| 35 |
|
| 36 |
// Tracking consent pop-up + callout. Registered here (during |
| 37 |
// plugins_loaded) so its lifecycle hooks (activated_plugin, |
| 38 |
// upgrader_process_complete) are in place before those actions |
| 39 |
// fire on a plugin activation or update request. |
| 40 |
new Consent(); |
| 41 |
|
| 42 |
// Init hooks. |
| 43 |
add_action( 'init', array( $this, 'init' ) ); |
| 44 |
add_action( 'rest_api_init', array( $this, 'init_rest_api' ), 20 ); |
| 45 |
add_filter( 'query_vars', array( $this, 'query_vars' ) ); |
| 46 |
|
| 47 |
// Headers for API discoverability. |
| 48 |
add_filter( 'rest_pre_serve_request', array( $this, 'rest_pre_serve_request' ), 5, 4 ); |
| 49 |
add_action( 'send_headers', array( $this, 'send_headers' ), 99, 1 ); |
| 50 |
add_action( 'send_headers', array( $this, 'remove_x_frame_options' ), 9999, 1 ); |
| 51 |
|
| 52 |
/* |
| 53 |
* Add JWT authentication filter. |
| 54 |
* |
| 55 |
* Hook order: plugins_loaded -> init (determine_current_user) -> rest_api_init |
| 56 |
* |
| 57 |
* This filter runs at priority 20 (after WordPress core's cookie auth at priority 10). |
| 58 |
* It must be registered here (during plugins_loaded) because determine_current_user |
| 59 |
* fires during 'init', which is BEFORE rest_api_init where our API class loads. |
| 60 |
*/ |
| 61 |
add_filter( 'determine_current_user', array( $this, 'determine_current_user_early' ), 20 ); |
| 62 |
} |
| 63 |
|
| 64 |
/** |
| 65 |
* Early authentication check for JWT tokens. |
| 66 |
* |
| 67 |
* This runs BEFORE rest_api_init, so we can authenticate users before WP REST API |
| 68 |
* permission callbacks run. This is especially important for authorization via |
| 69 |
* query parameter (?authorization=Bearer...) which some servers require. |
| 70 |
* |
| 71 |
* Note: We don't check for X-WCPOS header here because: |
| 72 |
* 1. The header check uses getallheaders() which may not work in all environments |
| 73 |
* 2. JWT authentication should work regardless - the token itself is proof of WCPOS usage |
| 74 |
* 3. Invalid tokens (non-WCPOS) will fail validation anyway |
| 75 |
* |
| 76 |
* @param false|int $user_id User ID if one has been determined, false otherwise. |
| 77 |
* |
| 78 |
* @return false|int User ID if authenticated, original value otherwise. |
| 79 |
*/ |
| 80 |
public function determine_current_user_early( $user_id ) { |
| 81 |
// Skip if user already authenticated. |
| 82 |
if ( ! empty( $user_id ) ) { |
| 83 |
return $user_id; |
| 84 |
} |
| 85 |
|
| 86 |
// Check for authorization token (header or param). |
| 87 |
$auth_header = $this->get_auth_header_early(); |
| 88 |
if ( ! \is_string( $auth_header ) || empty( $auth_header ) ) { |
| 89 |
return $user_id; |
| 90 |
} |
| 91 |
|
| 92 |
// Extract Bearer token. |
| 93 |
list( $token ) = sscanf( $auth_header, 'Bearer %s' ); |
| 94 |
if ( ! $token ) { |
| 95 |
return $user_id; |
| 96 |
} |
| 97 |
|
| 98 |
// Validate token - this will fail for non-WCPOS tokens. |
| 99 |
$auth_service = AuthService::instance(); |
| 100 |
$decoded_token = $auth_service->validate_token( $token ); |
| 101 |
|
| 102 |
if ( is_wp_error( $decoded_token ) ) { |
| 103 |
return $user_id; |
| 104 |
} |
| 105 |
|
| 106 |
// Return the authenticated user ID. |
| 107 |
return absint( $decoded_token->data->user->id ); |
| 108 |
} |
| 109 |
|
| 110 |
/** |
| 111 |
* Load the required resources. |
| 112 |
*/ |
| 113 |
public function init(): void { |
| 114 |
$this->init_common(); |
| 115 |
$this->init_frontend(); |
| 116 |
$this->init_admin(); |
| 117 |
$this->init_integrations(); |
| 118 |
} |
| 119 |
|
| 120 |
/** |
| 121 |
* Loads the POS API and duck punches the WC REST API. |
| 122 |
*/ |
| 123 |
public function init_rest_api(): void { |
| 124 |
$is_wcpos_request = woocommerce_pos_request(); |
| 125 |
|
| 126 |
if ( $is_wcpos_request ) { |
| 127 |
new API(); |
| 128 |
} else { |
| 129 |
// Queue the registration at a later priority of the SAME |
| 130 |
// rest_api_init pass this method runs on (priority 20), so |
| 131 |
// register_rest_route() executes during the action as WP requires. |
| 132 |
// When this method is called outside the action (tests), the |
| 133 |
// add_action is simply inert. |
| 134 |
add_action( 'rest_api_init', array( $this, 'register_public_relay_routes' ), 30 ); |
| 135 |
$this->log_unmarked_wcpos_rest_request(); |
| 136 |
new WC_API(); |
| 137 |
} |
| 138 |
} |
| 139 |
|
| 140 |
/** |
| 141 |
* Register the relay's public consent-callback route for unmarked requests. |
| 142 |
* |
| 143 |
* The WCPOS Cloud Print relay proves site consent by fetching |
| 144 |
* print-jobs/relay-verification WITHOUT the WCPOS request marker, so this |
| 145 |
* single public route must exist even when the full WCPOS API is not |
| 146 |
* loaded. Everything else stays behind the marker. |
| 147 |
*/ |
| 148 |
public function register_public_relay_routes(): void { |
| 149 |
register_rest_route( |
| 150 |
SHORT_NAME . '/v1', |
| 151 |
'/print-jobs/relay-verification', |
| 152 |
array( |
| 153 |
'methods' => 'GET', |
| 154 |
'callback' => array( new API\Print_Jobs_Controller(), 'relay_verification' ), |
| 155 |
'permission_callback' => '__return_true', |
| 156 |
) |
| 157 |
); |
| 158 |
} |
| 159 |
|
| 160 |
/** |
| 161 |
* Log requests for a WCPOS namespace that omitted the required request marker. |
| 162 |
* |
| 163 |
* This runs before WCPOS routes are registered, so it captures the otherwise |
| 164 |
* silent rest_no_route response. Warnings are limited by API version to avoid |
| 165 |
* allowing repeated unauthenticated requests to flood WooCommerce logs. |
| 166 |
*/ |
| 167 |
private function log_unmarked_wcpos_rest_request(): void { |
| 168 |
global $wp; |
| 169 |
|
| 170 |
$route = isset( $wp->query_vars['rest_route'] ) |
| 171 |
? '/' . ltrim( sanitize_text_field( wp_unslash( (string) $wp->query_vars['rest_route'] ) ), '/' ) |
| 172 |
: ''; |
| 173 |
|
| 174 |
if ( 1 !== preg_match( '#^/wcpos/v([12])(?:/|$)#', $route, $matches ) ) { |
| 175 |
return; |
| 176 |
} |
| 177 |
|
| 178 |
// The relay's consent callback is expected unmarked traffic (see |
| 179 |
// register_public_relay_routes()), not a misconfigured client. |
| 180 |
if ( '/wcpos/v1/print-jobs/relay-verification' === $route ) { |
| 181 |
return; |
| 182 |
} |
| 183 |
|
| 184 |
$transient = 'wcpos_missing_request_marker_v' . $matches[1]; |
| 185 |
if ( false !== get_transient( $transient ) ) { |
| 186 |
return; |
| 187 |
} |
| 188 |
|
| 189 |
set_transient( $transient, 1, 5 * MINUTE_IN_SECONDS ); |
| 190 |
Logger::warning( $route . ': missing WCPOS request marker.' ); |
| 191 |
} |
| 192 |
|
| 193 |
/** |
| 194 |
* Adds 'wcpos' to the query variables allowed before processing. |
| 195 |
* |
| 196 |
* Allows (publicly allowed) query vars to be added, removed, or changed prior |
| 197 |
* to executing the query. Needed to allow custom rewrite rules using your own arguments |
| 198 |
* to work, or any other custom query variables you want to be publicly available. |
| 199 |
* |
| 200 |
* @param string[] $query_vars The array of allowed query variable names. |
| 201 |
* |
| 202 |
* @return string[] The array of allowed query variable names. |
| 203 |
*/ |
| 204 |
public function query_vars( array $query_vars ): array { |
| 205 |
$query_vars[] = SHORT_NAME; |
| 206 |
|
| 207 |
return $query_vars; |
| 208 |
} |
| 209 |
|
| 210 |
/** |
| 211 |
* Allow pre-flight requests from WCPOS Desktop and Mobile Apps |
| 212 |
* Note: pre-flight requests cannot have headers, so I can't filter by pos request |
| 213 |
* See: https://fetch.spec.whatwg.org/#cors-preflight-fetch. |
| 214 |
* |
| 215 |
* @param bool $served Whether the request has already been served. |
| 216 |
* Default false. |
| 217 |
* @param WP_HTTP_Response $result Result to send to the client. Usually a `WP_REST_Response`. |
| 218 |
* @param WP_REST_Request $request Request used to generate the response. |
| 219 |
* @param WP_REST_Server $server Server instance. |
| 220 |
* |
| 221 |
* @return bool $served |
| 222 |
*/ |
| 223 |
public function rest_pre_serve_request( $served, WP_HTTP_Response $result, WP_REST_Request $request, WP_REST_Server $server ) { |
| 224 |
if ( 'OPTIONS' == $request->get_method() ) { |
| 225 |
$allow_headers = array( |
| 226 |
'Authorization', // For user-agent authentication with a server. |
| 227 |
'X-WP-Nonce', // WordPress-specific header, used for CSRF protection. |
| 228 |
'Content-Disposition', // Informs how to process the response data. |
| 229 |
'Content-MD5', // For verifying data integrity. |
| 230 |
'Content-Type', // Specifies the media type of the resource. |
| 231 |
'X-HTTP-Method-Override', // Used to override the HTTP method. |
| 232 |
'X-WCPOS', // Used to identify WCPOS requests. |
| 233 |
); |
| 234 |
|
| 235 |
$server->send_header( 'Access-Control-Allow-Origin', '*' ); |
| 236 |
$server->send_header( 'Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE' ); |
| 237 |
$server->send_header( 'Access-Control-Allow-Headers', implode( ', ', $allow_headers ) ); |
| 238 |
} |
| 239 |
|
| 240 |
return $served; |
| 241 |
} |
| 242 |
|
| 243 |
/** |
| 244 |
* Allow HEAD checks for WP API Link URL and server uptime |
| 245 |
* Fires once the requested HTTP headers for caching, content type, etc. have been sent. |
| 246 |
* |
| 247 |
* FIXME: Why is Link header not exposed sometimes on my development machine? |
| 248 |
* |
| 249 |
* @return void |
| 250 |
*/ |
| 251 |
public function send_headers(): void { |
| 252 |
// some server convert HEAD to GET method, so use this query param instead. |
| 253 |
if ( isset( $_GET['_method'] ) && 'head' === strtolower( sanitize_text_field( wp_unslash( $_GET['_method'] ) ) ) ) { |
| 254 |
header( 'Access-Control-Allow-Origin: *' ); |
| 255 |
header( 'Access-Control-Expose-Headers: Link' ); |
| 256 |
} |
| 257 |
} |
| 258 |
|
| 259 |
/** |
| 260 |
* Some security plugins will set X-Frame-Options: SAMEORIGIN/DENY, which will prevent the POS desktop |
| 261 |
* application from opening pages like the login in an iframe. |
| 262 |
* |
| 263 |
* For pages we need, we will remove the X-Frame-Options header. |
| 264 |
* |
| 265 |
* @param mixed $wp The WP object. |
| 266 |
* |
| 267 |
* @return void |
| 268 |
*/ |
| 269 |
public function remove_x_frame_options( $wp ): void { |
| 270 |
if ( woocommerce_pos_request() || isset( $wp->query_vars['wcpos-login'] ) ) { |
| 271 |
if ( ! headers_sent() && \function_exists( 'header_remove' ) ) { |
| 272 |
header_remove( 'X-Frame-Options' ); |
| 273 |
} |
| 274 |
} |
| 275 |
} |
| 276 |
|
| 277 |
/** |
| 278 |
* Get authorization header/param value. |
| 279 |
* |
| 280 |
* Checks multiple sources for the authorization token: |
| 281 |
* 1. HTTP_AUTHORIZATION server variable (standard) |
| 282 |
* 2. REDIRECT_HTTP_AUTHORIZATION (Apache CGI workaround) |
| 283 |
* 3. authorization query parameter (for servers that strip auth headers) |
| 284 |
* |
| 285 |
* @return false|string The authorization value or false if not found. |
| 286 |
*/ |
| 287 |
private function get_auth_header_early() { |
| 288 |
// Check HTTP_AUTHORIZATION (not empty - htaccess SetEnvIf can set empty value). |
| 289 |
if ( ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) ) { |
| 290 |
return sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) ); |
| 291 |
} |
| 292 |
|
| 293 |
// Check REDIRECT_HTTP_AUTHORIZATION (Apache CGI). |
| 294 |
if ( ! empty( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) { |
| 295 |
return sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ); |
| 296 |
} |
| 297 |
|
| 298 |
// Check authorization query param. |
| 299 |
if ( ! empty( $_GET['authorization'] ) ) { |
| 300 |
return sanitize_text_field( wp_unslash( $_GET['authorization'] ) ); |
| 301 |
} |
| 302 |
|
| 303 |
return false; |
| 304 |
} |
| 305 |
|
| 306 |
/** |
| 307 |
* Common initializations. |
| 308 |
*/ |
| 309 |
private function init_common(): void { |
| 310 |
// init the Services. |
| 311 |
SettingsService::instance(); |
| 312 |
AuthService::instance(); |
| 313 |
Extensions::instance(); |
| 314 |
Receipt_Snapshot_Store::instance(); |
| 315 |
|
| 316 |
// init other functionality needed by both frontend and admin. |
| 317 |
new i18n(); |
| 318 |
new Gateways(); |
| 319 |
new Products(); |
| 320 |
new Orders(); |
| 321 |
new Emails(); |
| 322 |
new Templates(); |
| 323 |
new Services\Print_Job_Service(); |
| 324 |
new Services\Cloud_Print_Trigger_Service(); |
| 325 |
new Services\Cloud_Print_Submit_Service(); |
| 326 |
new Services\Cloud_Print_Relay_Service(); |
| 327 |
} |
| 328 |
|
| 329 |
/** |
| 330 |
* Frontend specific initializations. |
| 331 |
*/ |
| 332 |
private function init_frontend(): void { |
| 333 |
if ( ! is_admin() ) { |
| 334 |
new Template_Router(); |
| 335 |
new Form_Handler(); |
| 336 |
new Storefront_Receipts(); |
| 337 |
} |
| 338 |
} |
| 339 |
|
| 340 |
/** |
| 341 |
* Admin specific initializations. |
| 342 |
*/ |
| 343 |
private function init_admin(): void { |
| 344 |
if ( is_admin() ) { |
| 345 |
// Register AJAX handler before the branch so it's available during AJAX requests. |
| 346 |
add_action( 'wp_ajax_wcpos_track_upgrade_click_ajax', array( Menu::class, 'handle_upgrade_click_ajax' ) ); |
| 347 |
add_action( 'admin_post_wcpos_track_upgrade_click', array( Menu::class, 'handle_upgrade_click_redirect' ) ); |
| 348 |
|
| 349 |
if ( \defined( 'DOING_AJAX' ) && DOING_AJAX ) { |
| 350 |
new AJAX(); |
| 351 |
} else { |
| 352 |
new Admin(); |
| 353 |
} |
| 354 |
} |
| 355 |
} |
| 356 |
|
| 357 |
/** |
| 358 |
* Integrations. |
| 359 |
*/ |
| 360 |
private function init_integrations(): void { |
| 361 |
// WooCommerce Bookings - http://www.woothemes.com/products/woocommerce-bookings/ |
| 362 |
// if ( class_exists( 'WC-Bookings' ) ) { |
| 363 |
// new Integrations\Bookings(); |
| 364 |
// }. |
| 365 |
|
| 366 |
// Yoast SEO - https://wordpress.org/plugins/wordpress-seo/. |
| 367 |
if ( class_exists( 'WPSEO_Options' ) ) { |
| 368 |
new Integrations\WPSEO(); |
| 369 |
} |
| 370 |
|
| 371 |
// wePOS alters the WooCommerce REST API, breaking the expected schema |
| 372 |
// It's very bad form on their part, but we need to work around it. |
| 373 |
new Integrations\WePOS(); |
| 374 |
} |
| 375 |
} |
| 376 |
|