summaryrefslogtreecommitdiffstats
path: root/includes/class-cepis-woo-rest.php
blob: 561ca7e88b68d4949ba08b332c1d6ea285310be8 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
<?php
/**
 * The optional server-side proxy route.
 *
 * @package CepisWooCommerce
 */

declare(strict_types=1);

defined( 'ABSPATH' ) || exit;

/**
 * Exposes `GET /wp-json/cepis/v1/lookup/{token}/{cep}`, which answers exactly
 * what cep.is answers.
 *
 * The route is only registered when the store has turned proxy mode on. It is
 * off by default because the browser can reach cep.is directly in about twenty
 * milliseconds against a Cloudflare edge cache, and putting a WordPress
 * bootstrap in front of that — on the checkout page, once per settled CEP —
 * spends latency where a store can least afford it. Turn it on when a
 * Content-Security-Policy forbids external XHR, or to get the server-side
 * cache in {@see Cepis_Woo_Lookup}.
 *
 * The token is a path segment rather than a header because the widget's
 * request is a plain cross-origin-safe GET with no custom headers.
 * {@see Cepis_Woo_Token} explains why it is not a session-bound nonce.
 */
final class Cepis_Woo_Rest {

	/**
	 * REST namespace for the proxy.
	 */
	public const NAMESPACE_V1 = 'cepis/v1';

	/**
	 * Register the route, when the store asked for it.
	 */
	public static function init(): void {
		if ( ! Cepis_Woo_Options::enabled() || 'proxy' !== Cepis_Woo_Options::mode() ) {
			return;
		}

		add_action( 'rest_api_init', array( self::class, 'register' ) );
	}

	/**
	 * Declare the route.
	 */
	public static function register(): void {
		register_rest_route(
			self::NAMESPACE_V1,
			'/lookup/(?P<token>[A-Za-z0-9]{1,64})/(?P<cep>[0-9]{8})',
			array(
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => array( self::class, 'lookup' ),
				'permission_callback' => array( self::class, 'permitted' ),
				'args'                => array(
					'token' => array(
						'type'              => 'string',
						'required'          => true,
						'sanitize_callback' => 'sanitize_key',
					),
					'cep'   => array(
						'type'              => 'string',
						'required'          => true,
						'sanitize_callback' => 'sanitize_key',
						'validate_callback' => static fn( $value ): bool => 1 === preg_match( '/^[0-9]{8}$/', (string) $value ),
					),
				),
			)
		);
	}

	/**
	 * Reject anything that does not carry a live token.
	 *
	 * @param WP_REST_Request $request Incoming request.
	 *
	 * @return true|WP_Error True when the token checks out.
	 */
	public static function permitted( WP_REST_Request $request ): bool|WP_Error {
		if ( Cepis_Woo_Token::verify( (string) $request['token'] ) ) {
			return true;
		}

		return new WP_Error(
			'cepis_invalid_token',
			__( 'Lookup token missing, invalid or expired. Reload the page.', 'cepis-woocommerce' ),
			array( 'status' => 403 )
		);
	}

	/**
	 * Answer one lookup.
	 *
	 * @param WP_REST_Request $request Incoming request.
	 */
	public static function lookup( WP_REST_Request $request ): WP_REST_Response {
		$result = Cepis_Woo_Lookup::resolve( (string) $request['cep'] );

		$response = new WP_REST_Response( $result['body'], $result['status'] );
		$response->header( 'Cache-Control', 'no-store' );

		if ( null !== $result['retry_after'] ) {
			$response->header( 'Retry-After', (string) $result['retry_after'] );
		}

		return $response;
	}

	/**
	 * The API root to hand the browser, with a live token already in it.
	 *
	 * Shaped so that appending `/{cep}` to it reaches this route, which is what
	 * the widget does with its `baseUrl`.
	 */
	public static function base_url(): string {
		return untrailingslashit( rest_url( self::NAMESPACE_V1 . '/lookup/' . Cepis_Woo_Token::create() ) );
	}
}