summaryrefslogtreecommitdiffstats
path: root/includes
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 16:41:48 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 16:47:14 -0300
commit535dbda6b48171f8115b455e54790de94d97d42a (patch)
tree5c0410011e61a883bdf063ee612875a637fb1d22 /includes
downloadwoocommerce-29190494c82855f0f0ce7fb16f831c8f971022c5.tar.gz
Initial importv0.1.0
Diffstat (limited to 'includes')
-rw-r--r--includes/class-cepis-woo-admin.php74
-rw-r--r--includes/class-cepis-woo-assets.php183
-rw-r--r--includes/class-cepis-woo-lookup.php170
-rw-r--r--includes/class-cepis-woo-messages.php60
-rw-r--r--includes/class-cepis-woo-options.php105
-rw-r--r--includes/class-cepis-woo-rest.php120
-rw-r--r--includes/class-cepis-woo-settings.php135
-rw-r--r--includes/class-cepis-woo-token.php76
-rw-r--r--includes/class-cepis-woo-transport.php58
9 files changed, 981 insertions, 0 deletions
diff --git a/includes/class-cepis-woo-admin.php b/includes/class-cepis-woo-admin.php
new file mode 100644
index 0000000..efb1959
--- /dev/null
+++ b/includes/class-cepis-woo-admin.php
@@ -0,0 +1,74 @@
+<?php
+/**
+ * Admin-side wiring.
+ *
+ * @package CepisWooCommerce
+ */
+
+declare(strict_types=1);
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Puts the settings screen where a store owner will look for it, and links to
+ * it from the plugins list.
+ */
+final class Cepis_Woo_Admin {
+
+ /**
+ * Hook everything the admin needs.
+ */
+ public static function init(): void {
+ add_filter( 'woocommerce_get_settings_pages', array( self::class, 'register_settings_page' ) );
+ add_filter( 'plugin_action_links_' . plugin_basename( CEPIS_WOO_FILE ), array( self::class, 'action_links' ) );
+ }
+
+ /**
+ * Add the settings tab to WooCommerce's own settings screen.
+ *
+ * The class is required here rather than at load time because it extends
+ * `WC_Settings_Page`, which does not exist until WooCommerce has booted its
+ * admin.
+ *
+ * @param array<int, mixed> $pages Settings pages WooCommerce has collected.
+ *
+ * @return array<int, mixed>
+ */
+ public static function register_settings_page( array $pages ): array {
+ if ( ! class_exists( 'WC_Settings_Page' ) ) {
+ return $pages;
+ }
+
+ require_once CEPIS_WOO_DIR . 'includes/class-cepis-woo-settings.php';
+
+ $pages[] = new Cepis_Woo_Settings();
+
+ return $pages;
+ }
+
+ /**
+ * Add a "Settings" link to the plugin's row.
+ *
+ * @param array<int|string, string> $links Existing action links.
+ *
+ * @return array<int|string, string>
+ */
+ public static function action_links( array $links ): array {
+ if ( ! current_user_can( 'manage_woocommerce' ) ) {
+ return $links;
+ }
+
+ $url = admin_url( 'admin.php?page=wc-settings&tab=cepis' );
+
+ array_unshift(
+ $links,
+ sprintf(
+ '<a href="%s">%s</a>',
+ esc_url( $url ),
+ esc_html__( 'Settings', 'cepis-woocommerce' )
+ )
+ );
+
+ return $links;
+ }
+}
diff --git a/includes/class-cepis-woo-assets.php b/includes/class-cepis-woo-assets.php
new file mode 100644
index 0000000..def6fe2
--- /dev/null
+++ b/includes/class-cepis-woo-assets.php
@@ -0,0 +1,183 @@
+<?php
+/**
+ * What gets loaded on the checkout page, and with what configuration.
+ *
+ * @package CepisWooCommerce
+ */
+
+declare(strict_types=1);
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Detects which of WooCommerce's two checkouts is on screen and loads the
+ * matching integration.
+ *
+ * They are not variations of one another. The classic checkout is a server
+ * rendered form, so the widget can own the fields directly. The Cart/Checkout
+ * Blocks are React, the fields are controlled inputs, and writing to them from
+ * outside is at best ignored on the next render — so there the plugin never
+ * touches the DOM and drives the `wc/store/cart` data store instead.
+ */
+final class Cepis_Woo_Assets {
+
+ /**
+ * Script handle for the vendored widget.
+ */
+ private const WIDGET = 'cepis-autofill';
+
+ /**
+ * Hook the enqueue.
+ */
+ public static function init(): void {
+ add_action( 'wp_enqueue_scripts', array( self::class, 'enqueue' ) );
+ }
+
+ /**
+ * Load the widget plus whichever integration this checkout needs.
+ */
+ public static function enqueue(): void {
+ $checkout = self::checkout_type();
+
+ if ( '' === $checkout || ! Cepis_Woo_Options::enabled() ) {
+ return;
+ }
+
+ wp_register_script(
+ self::WIDGET,
+ CEPIS_WOO_URL . 'assets/js/cepis-autofill.min.js',
+ array(),
+ CEPIS_WOO_VERSION,
+ true
+ );
+
+ $handle = 'cepis-woocommerce-' . $checkout;
+ $dependencies = 'blocks' === $checkout
+ ? array( self::WIDGET, 'wp-data', 'wc-blocks-data-store' )
+ : array( self::WIDGET, 'jquery' );
+
+ wp_enqueue_script(
+ $handle,
+ CEPIS_WOO_URL . 'assets/js/cepis-woocommerce-' . $checkout . '.js',
+ $dependencies,
+ CEPIS_WOO_VERSION,
+ true
+ );
+
+ wp_add_inline_script(
+ $handle,
+ 'window.cepisWooCommerce = ' . wp_json_encode( self::config( $checkout ), JSON_HEX_TAG | JSON_HEX_AMP ) . ';',
+ 'before'
+ );
+ }
+
+ /**
+ * Which checkout is being rendered.
+ *
+ * @return string `blocks`, `classic`, or an empty string when this is not a
+ * checkout the plugin should touch.
+ */
+ private static function checkout_type(): string {
+ if ( ! function_exists( 'is_checkout' ) || ! is_checkout() ) {
+ return '';
+ }
+
+ if ( function_exists( 'is_order_received_page' ) && is_order_received_page() ) {
+ return '';
+ }
+
+ $post = get_post();
+
+ return ( $post instanceof WP_Post && has_block( 'woocommerce/checkout', $post ) ) ? 'blocks' : 'classic';
+ }
+
+ /**
+ * Everything the browser needs, in one object.
+ *
+ * @param string $checkout Either `blocks` or `classic`.
+ *
+ * @return array<string, mixed>
+ */
+ private static function config( string $checkout ): array {
+ return array(
+ 'baseUrl' => 'proxy' === Cepis_Woo_Options::mode()
+ ? Cepis_Woo_Rest::base_url()
+ : Cepis_Woo_Options::base_url(),
+ 'overwrite' => Cepis_Woo_Options::overwrite(),
+ 'focusNumero' => Cepis_Woo_Options::focus_number(),
+ 'debounceMs' => 250,
+ 'timeoutMs' => 8000,
+ 'messages' => Cepis_Woo_Messages::all(),
+ 'fields' => 'blocks' === $checkout ? self::blocks_fields() : self::classic_fields(),
+ );
+ }
+
+ /**
+ * CSS selectors for the classic checkout, per address section.
+ *
+ * `billing_number` and `billing_neighborhood` are not WooCommerce fields —
+ * they come from the "Brazilian Market on WooCommerce" plugin, which is the
+ * de-facto way a Brazilian store gets a house-number and a neighbourhood
+ * box. A selector that matches nothing is skipped, so listing them costs
+ * nothing on a store that does not have them.
+ *
+ * There is deliberately no fallback onto `address_2` for the neighbourhood:
+ * in WooCommerce that field means "apartment, suite, unit", and putting a
+ * neighbourhood there produces a wrong address on the shipping label.
+ *
+ * @return array<string, array<string, string>>
+ */
+ private static function classic_fields(): array {
+ $fields = array();
+
+ foreach ( array( 'billing', 'shipping' ) as $section ) {
+ $fields[ $section ] = array(
+ 'cep' => '#' . $section . '_postcode',
+ 'logradouro' => '#' . $section . '_address_1',
+ 'bairro' => '#' . $section . '_neighborhood',
+ 'localidade' => '#' . $section . '_city',
+ 'uf' => '#' . $section . '_state',
+ 'numero' => '#' . $section . '_number',
+ );
+ }
+
+ /**
+ * Filters the field selectors used on the classic checkout.
+ *
+ * Keys are the API's own field names; values are CSS selectors. A theme
+ * or one-page-checkout plugin that renames the fields hooks in here.
+ *
+ * @param array<string, array<string, string>> $fields Selectors per address section.
+ */
+ return (array) apply_filters( 'cepis_woocommerce_classic_fields', $fields );
+ }
+
+ /**
+ * Store API address keys for the Cart/Checkout Blocks.
+ *
+ * The block checkout has no house-number and no neighbourhood field, and no
+ * plugin currently adds one: "Brazilian Market on WooCommerce" only extends
+ * the classic form. A store that registers its own with
+ * `woocommerce_register_additional_checkout_field()` can map it here, using
+ * the namespaced key the Store API exposes it under.
+ *
+ * @return array<string, string>
+ */
+ private static function blocks_fields(): array {
+ $fields = array(
+ 'logradouro' => 'address_1',
+ 'localidade' => 'city',
+ 'uf' => 'state',
+ );
+
+ /**
+ * Filters the Store API address keys written on the block checkout.
+ *
+ * Keys are the API's own field names; values are keys of the address
+ * object in the `wc/store/cart` data store.
+ *
+ * @param array<string, string> $fields Address keys per API field.
+ */
+ return (array) apply_filters( 'cepis_woocommerce_blocks_fields', $fields );
+ }
+}
diff --git a/includes/class-cepis-woo-lookup.php b/includes/class-cepis-woo-lookup.php
new file mode 100644
index 0000000..47e3f16
--- /dev/null
+++ b/includes/class-cepis-woo-lookup.php
@@ -0,0 +1,170 @@
+<?php
+/**
+ * Server-side CEP lookups, cached.
+ *
+ * @package CepisWooCommerce
+ */
+
+declare(strict_types=1);
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Resolves a CEP with the bundled `Cepis\Client` and remembers the answer in a
+ * transient.
+ *
+ * Only the proxy mode goes through here. In the default direct mode the
+ * browser talks to cep.is itself and no PHP runs at all.
+ */
+final class Cepis_Woo_Lookup {
+
+ /**
+ * Prefix for the transient holding one CEP's answer.
+ */
+ private const CACHE_PREFIX = 'cepis_woo_';
+
+ /**
+ * How long a resolved address is kept.
+ *
+ * The DNE database behind the API is regenerated fortnightly, so a week is
+ * half a refresh cycle: an address can be at most one release behind, while
+ * a CEP a store sees repeatedly still costs one outbound request a week.
+ */
+ private const HIT_TTL = WEEK_IN_SECONDS;
+
+ /**
+ * How long a "this CEP does not exist" is kept.
+ *
+ * Much shorter than a hit, and for a different reason: a CEP absent today
+ * can appear in the next fortnightly base, and telling a shopper their
+ * brand-new street does not exist for two weeks is worse than making the
+ * request again tomorrow.
+ */
+ private const MISS_TTL = DAY_IN_SECONDS;
+
+ /**
+ * Look a CEP up, from cache when possible.
+ *
+ * Never throws: every outcome is described by the returned status and body,
+ * in exactly the shapes the public API uses, so the browser sees the same
+ * thing whichever mode the store is in.
+ *
+ * @param string $cep Eight digits, already validated by the caller.
+ *
+ * @return array{status:int, body:array<string, mixed>, retry_after:?int}
+ */
+ public static function resolve( string $cep ): array {
+ $cached = get_transient( self::CACHE_PREFIX . $cep );
+
+ if ( is_array( $cached ) ) {
+ return isset( $cached['miss'] )
+ ? self::not_found()
+ : array(
+ 'status' => 200,
+ 'body' => $cached,
+ 'retry_after' => null,
+ );
+ }
+
+ try {
+ $address = self::client()->lookup( $cep );
+ } catch ( Cepis\CepNotFoundException ) {
+ set_transient( self::CACHE_PREFIX . $cep, array( 'miss' => true ), self::MISS_TTL );
+
+ return self::not_found();
+ } catch ( Cepis\InvalidCepException ) {
+ return array(
+ 'status' => 400,
+ 'body' => array(
+ 'erro' => 'cep_invalido',
+ 'mensagem' => 'CEP deve ter 8 dígitos',
+ ),
+ 'retry_after' => null,
+ );
+ } catch ( Cepis\RateLimitedException $e ) {
+ return array(
+ 'status' => 429,
+ 'body' => array(
+ 'erro' => 'limite',
+ 'mensagem' => 'Muitas consultas em pouco tempo',
+ ),
+ 'retry_after' => null === $e->retryAfter ? null : (int) $e->retryAfter,
+ );
+ } catch ( Cepis\CepisException ) {
+ return array(
+ 'status' => 502,
+ 'body' => array(
+ 'erro' => 'interno',
+ 'mensagem' => 'falha interna',
+ ),
+ 'retry_after' => null,
+ );
+ }
+
+ $body = $address->raw;
+ set_transient( self::CACHE_PREFIX . $cep, $body, self::HIT_TTL );
+
+ return array(
+ 'status' => 200,
+ 'body' => $body,
+ 'retry_after' => null,
+ );
+ }
+
+ /**
+ * Drop every cached answer.
+ *
+ * Used by the settings screen when the API root changes, since answers from
+ * one instance say nothing about another.
+ */
+ public static function flush(): void {
+ global $wpdb;
+
+ $like = $wpdb->esc_like( '_transient_' . self::CACHE_PREFIX ) . '%';
+
+ $names = $wpdb->get_col(
+ $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", $like )
+ );
+
+ foreach ( (array) $names as $name ) {
+ delete_transient( substr( (string) $name, strlen( '_transient_' ) ) );
+ }
+ }
+
+ /**
+ * The client, built once per request.
+ */
+ private static function client(): Cepis\Client {
+ static $client = null;
+
+ if ( null === $client ) {
+ $client = new Cepis\Client(
+ baseUrl: Cepis_Woo_Options::base_url(),
+ timeout: 5.0,
+ maxAttempts: 2,
+ totalTimeout: 8.0,
+ userAgent: 'cepis-woocommerce/' . CEPIS_WOO_VERSION . ' (+https://cep.is)',
+ transport: new Cepis_Woo_Transport(),
+ );
+ }
+
+ return $client;
+ }
+
+ /**
+ * The API's own 404 body, reproduced so the browser cannot tell the two
+ * modes apart.
+ *
+ * @return array{status:int, body:array<string, mixed>, retry_after:?int}
+ */
+ private static function not_found(): array {
+ return array(
+ 'status' => 404,
+ 'body' => array(
+ 'erro' => 'cep_nao_encontrado',
+ 'mensagem' => 'CEP não existe na base do DNE',
+ ),
+ 'retry_after' => null,
+ );
+ }
+}
diff --git a/includes/class-cepis-woo-messages.php b/includes/class-cepis-woo-messages.php
new file mode 100644
index 0000000..201fe4e
--- /dev/null
+++ b/includes/class-cepis-woo-messages.php
@@ -0,0 +1,60 @@
+<?php
+/**
+ * The strings the shopper sees while a CEP is looked up.
+ *
+ * @package CepisWooCommerce
+ */
+
+declare(strict_types=1);
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * The widget ships its own Portuguese messages; these replace them so that a
+ * store running in another locale gets the checkout in that locale, and so a
+ * store owner can reword them with the usual translation tooling.
+ *
+ * Keys are the widget's status kinds and error codes. Anything not listed
+ * keeps the widget's own wording.
+ */
+final class Cepis_Woo_Messages {
+
+ /**
+ * Build the message table.
+ *
+ * Must be called at or after `init`: it is the first thing in the plugin
+ * that translates.
+ *
+ * @return array<string, string> Message per status kind or error code.
+ */
+ public static function all(): array {
+ $messages = array(
+ 'idle' => '',
+ 'loading' => __( 'Looking the address up…', 'cepis-woocommerce' ),
+ 'found' => __( 'Address found.', 'cepis-woocommerce' ),
+ 'not_found' => __( 'CEP not found. Check the number or fill the address in by hand.', 'cepis-woocommerce' ),
+ 'invalid' => __( 'Invalid CEP. Type all 8 digits.', 'cepis-woocommerce' ),
+ 'rate_limited' => __( 'Too many lookups in a short time. Wait a few seconds and try again.', 'cepis-woocommerce' ),
+ 'server' => __( 'The CEP service is unavailable. Fill the address in by hand.', 'cepis-woocommerce' ),
+ 'network' => __( 'Could not look the CEP up. Check your connection and try again.', 'cepis-woocommerce' ),
+ 'timeout' => __( 'The CEP lookup took too long. Try again or fill the address in by hand.', 'cepis-woocommerce' ),
+ 'invalid_response' => __( 'Unexpected answer from the CEP service. Fill the address in by hand.', 'cepis-woocommerce' ),
+ );
+
+ /**
+ * Filters the messages shown at the checkout while a CEP is resolved.
+ *
+ * @param array<string, string> $messages Message per status kind or error code.
+ */
+ $messages = (array) apply_filters( 'cepis_woocommerce_messages', $messages );
+
+ $clean = array();
+ foreach ( $messages as $key => $message ) {
+ if ( is_string( $key ) && is_scalar( $message ) ) {
+ $clean[ sanitize_key( $key ) ] = wp_strip_all_tags( (string) $message );
+ }
+ }
+
+ return $clean;
+ }
+}
diff --git a/includes/class-cepis-woo-options.php b/includes/class-cepis-woo-options.php
new file mode 100644
index 0000000..7a4a0b0
--- /dev/null
+++ b/includes/class-cepis-woo-options.php
@@ -0,0 +1,105 @@
+<?php
+/**
+ * Stored settings and their defaults.
+ *
+ * @package CepisWooCommerce
+ */
+
+declare(strict_types=1);
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Reads the plugin's options, normalising whatever is in the database into the
+ * small set of values the rest of the plugin is allowed to see.
+ *
+ * Every getter returns a value from a fixed set: an option edited by hand in
+ * the database, or left over from an older release, can never reach the
+ * browser or the HTTP client as-is.
+ */
+final class Cepis_Woo_Options {
+
+ /**
+ * Option names, so that the settings screen and `uninstall.php` cannot
+ * drift apart.
+ */
+ public const NAMES = array(
+ 'cepis_woo_enabled',
+ 'cepis_woo_mode',
+ 'cepis_woo_overwrite',
+ 'cepis_woo_focus_number',
+ 'cepis_woo_base_url',
+ );
+
+ /**
+ * The public API, and the default for the `base_url` setting.
+ */
+ public const DEFAULT_BASE_URL = 'https://cep.is';
+
+ /**
+ * Whether the autofill should run at all.
+ */
+ public static function enabled(): bool {
+ return 'yes' === get_option( 'cepis_woo_enabled', 'yes' );
+ }
+
+ /**
+ * Where the browser sends its lookups.
+ *
+ * `direct` has the browser call cep.is itself: one cross-origin GET against
+ * an edge cache, with no PHP in the path. `proxy` routes the lookup through
+ * this site's REST API instead, for stores whose Content-Security-Policy
+ * forbids external XHR or whose owner wants the server-side cache.
+ *
+ * @return string Either `direct` or `proxy`.
+ */
+ public static function mode(): string {
+ return 'proxy' === get_option( 'cepis_woo_mode', 'direct' ) ? 'proxy' : 'direct';
+ }
+
+ /**
+ * What to do with a checkout field that already holds a value.
+ *
+ * @return bool|string `"auto"`, `true` or `false`, in the shape the widget
+ * expects for its own `overwrite` option.
+ */
+ public static function overwrite(): bool|string {
+ $value = (string) get_option( 'cepis_woo_overwrite', 'auto' );
+
+ if ( 'always' === $value ) {
+ return true;
+ }
+ if ( 'never' === $value ) {
+ return false;
+ }
+
+ return 'auto';
+ }
+
+ /**
+ * Whether to move the cursor to the house-number field after a hit.
+ *
+ * Only ever does anything on the classic checkout, and only when a
+ * house-number field exists.
+ */
+ public static function focus_number(): bool {
+ return 'yes' === get_option( 'cepis_woo_focus_number', 'yes' );
+ }
+
+ /**
+ * Root of the API to query, without a trailing slash.
+ *
+ * Overridable so a store can point at its own instance, and so the test
+ * suite can point at a fake one.
+ */
+ public static function base_url(): string {
+ $value = (string) get_option( 'cepis_woo_base_url', self::DEFAULT_BASE_URL );
+ $value = untrailingslashit( trim( $value ) );
+
+ if ( '' === $value || ! preg_match( '#^https?://#i', $value ) ) {
+ return self::DEFAULT_BASE_URL;
+ }
+
+ return $value;
+ }
+}
diff --git a/includes/class-cepis-woo-rest.php b/includes/class-cepis-woo-rest.php
new file mode 100644
index 0000000..561ca7e
--- /dev/null
+++ b/includes/class-cepis-woo-rest.php
@@ -0,0 +1,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() ) );
+ }
+}
diff --git a/includes/class-cepis-woo-settings.php b/includes/class-cepis-woo-settings.php
new file mode 100644
index 0000000..3fa73a1
--- /dev/null
+++ b/includes/class-cepis-woo-settings.php
@@ -0,0 +1,135 @@
+<?php
+/**
+ * The settings tab under WooCommerce.
+ *
+ * @package CepisWooCommerce
+ */
+
+declare(strict_types=1);
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * WooCommerce → Settings → CEP autofill.
+ *
+ * Extending `WC_Settings_Page` rather than rolling an options screen means the
+ * capability check, the `woocommerce-settings` nonce and the per-type
+ * sanitisation of every field come from WooCommerce, which is the code most
+ * likely to stay correct as WordPress changes. What is added on top is a
+ * second, explicit check on save and a stricter sanitiser for the one free-text
+ * field.
+ */
+final class Cepis_Woo_Settings extends WC_Settings_Page {
+
+ /**
+ * Identify the tab.
+ */
+ public function __construct() {
+ $this->id = 'cepis';
+ $this->label = __( 'CEP autofill', 'cepis-woocommerce' );
+
+ parent::__construct();
+
+ add_filter( 'woocommerce_admin_settings_sanitize_option_cepis_woo_base_url', array( $this, 'sanitize_base_url' ) );
+ }
+
+ /**
+ * The fields on the tab.
+ *
+ * @return array<int, array<string, mixed>>
+ */
+ public function get_settings_for_default_section(): array {
+ return array(
+ array(
+ 'title' => __( 'CEP autofill', 'cepis-woocommerce' ),
+ 'type' => 'title',
+ 'desc' => __( 'Fills the checkout address from the CEP the shopper types, using official Correios data (DNE) from cep.is.', 'cepis-woocommerce' ),
+ 'id' => 'cepis_woo_section',
+ ),
+ array(
+ 'title' => __( 'Autofill', 'cepis-woocommerce' ),
+ 'desc' => __( 'Look the address up while the shopper types the CEP', 'cepis-woocommerce' ),
+ 'id' => 'cepis_woo_enabled',
+ 'type' => 'checkbox',
+ 'default' => 'yes',
+ ),
+ array(
+ 'title' => __( 'Lookups go through', 'cepis-woocommerce' ),
+ 'id' => 'cepis_woo_mode',
+ 'type' => 'radio',
+ 'default' => 'direct',
+ 'options' => array(
+ 'direct' => __( 'The shopper’s browser, straight to cep.is (recommended)', 'cepis-woocommerce' ),
+ 'proxy' => __( 'This site, through the WordPress REST API', 'cepis-woocommerce' ),
+ ),
+ 'desc_tip' => __( 'A direct lookup hits an edge cache and costs this store nothing. Route it through the site when a Content-Security-Policy forbids external requests from the page, or to cache answers server-side.', 'cepis-woocommerce' ),
+ ),
+ array(
+ 'title' => __( 'Fields already filled in', 'cepis-woocommerce' ),
+ 'id' => 'cepis_woo_overwrite',
+ 'type' => 'select',
+ 'class' => 'wc-enhanced-select',
+ 'default' => 'auto',
+ 'options' => array(
+ 'auto' => __( 'Keep what the shopper typed, replace what this plugin wrote', 'cepis-woocommerce' ),
+ 'never' => __( 'Only ever fill empty fields', 'cepis-woocommerce' ),
+ 'always' => __( 'Always overwrite', 'cepis-woocommerce' ),
+ ),
+ 'desc_tip' => __( 'A shopper who corrects the street by hand should not see the correction undone by the next lookup.', 'cepis-woocommerce' ),
+ ),
+ array(
+ 'title' => __( 'House number', 'cepis-woocommerce' ),
+ 'desc' => __( 'Move the cursor to the house-number field after a successful lookup', 'cepis-woocommerce' ),
+ 'id' => 'cepis_woo_focus_number',
+ 'type' => 'checkbox',
+ 'default' => 'yes',
+ 'desc_tip' => __( 'Classic checkout only, and only when the store has a house-number field.', 'cepis-woocommerce' ),
+ ),
+ array(
+ 'title' => __( 'API address', 'cepis-woocommerce' ),
+ 'id' => 'cepis_woo_base_url',
+ 'type' => 'url',
+ 'default' => Cepis_Woo_Options::DEFAULT_BASE_URL,
+ 'desc_tip' => __( 'Leave as it is unless you run your own instance of the API.', 'cepis-woocommerce' ),
+ ),
+ array(
+ 'type' => 'sectionend',
+ 'id' => 'cepis_woo_section',
+ ),
+ );
+ }
+
+ /**
+ * Save the tab.
+ *
+ * WooCommerce already gates this screen, but the check is repeated here so
+ * that the guarantee does not depend on a menu registration somewhere else
+ * staying correct.
+ */
+ public function save(): void {
+ if ( ! current_user_can( 'manage_woocommerce' ) ) {
+ wp_die( esc_html__( 'You are not allowed to change these settings.', 'cepis-woocommerce' ), 403 );
+ }
+
+ check_admin_referer( 'woocommerce-settings' );
+
+ $previous = Cepis_Woo_Options::base_url();
+
+ parent::save();
+
+ if ( Cepis_Woo_Options::base_url() !== $previous ) {
+ Cepis_Woo_Lookup::flush();
+ }
+ }
+
+ /**
+ * Keep the API address to an absolute http(s) URL.
+ *
+ * @param mixed $value Whatever the form posted.
+ */
+ public function sanitize_base_url( mixed $value ): string {
+ $url = esc_url_raw( trim( (string) $value ), array( 'http', 'https' ) );
+
+ return '' === $url ? Cepis_Woo_Options::DEFAULT_BASE_URL : untrailingslashit( $url );
+ }
+}
diff --git a/includes/class-cepis-woo-token.php b/includes/class-cepis-woo-token.php
new file mode 100644
index 0000000..1d1b9f4
--- /dev/null
+++ b/includes/class-cepis-woo-token.php
@@ -0,0 +1,76 @@
+<?php
+/**
+ * The token that guards the server-side proxy route.
+ *
+ * @package CepisWooCommerce
+ */
+
+declare(strict_types=1);
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * A short-lived nonce for the proxy route, derived the way WordPress derives
+ * its own but without binding to a login session.
+ *
+ * The bundled widget fetches with `credentials: "omit"` — deliberately, since
+ * a CEP lookup has no business carrying the shopper's cookies. The proxy
+ * therefore never sees an authentication cookie and every request reaches it
+ * anonymous, whether or not the shopper is logged in. A cookie-bound
+ * {@see wp_create_nonce()} would embed a session token in the page that the
+ * request could not present, so it would verify for guests and fail for
+ * everyone with an account: the worst possible failure mode, invisible in
+ * testing and broken in production.
+ *
+ * What the token has to defend against is the only threat this endpoint
+ * actually has — a third party pointing their own site at this store's proxy
+ * and using it as a free CEP gateway. A value tied to the site's salts and to
+ * {@see wp_nonce_tick()} does exactly that: it cannot be produced without the
+ * salts, and it stops working within a day.
+ *
+ * Same lifetime as a WordPress nonce: the current tick and the one before it
+ * are both accepted, so a checkout page held open overnight keeps working.
+ */
+final class Cepis_Woo_Token {
+
+ /**
+ * Namespaces the hash, so a token cannot be replayed against anything else
+ * on the site that hashes with the `nonce` salt.
+ */
+ private const ACTION = 'cepis_woo_proxy_lookup';
+
+ /**
+ * Mint the token to embed in the checkout page.
+ */
+ public static function create(): string {
+ return self::hash( wp_nonce_tick( self::ACTION ) );
+ }
+
+ /**
+ * Check a token presented by the browser.
+ *
+ * Compared with {@see hash_equals()} so that a wrong token takes the same
+ * time to reject as a right one.
+ *
+ * @param string $token Token as it arrived in the request.
+ */
+ public static function verify( string $token ): bool {
+ if ( '' === $token ) {
+ return false;
+ }
+
+ $tick = wp_nonce_tick( self::ACTION );
+
+ return hash_equals( self::hash( $tick ), $token )
+ || hash_equals( self::hash( $tick - 1 ), $token );
+ }
+
+ /**
+ * Derive the token for one tick.
+ *
+ * @param float $tick Nonce tick to derive from.
+ */
+ private static function hash( float $tick ): string {
+ return substr( wp_hash( $tick . '|' . self::ACTION, 'nonce' ), -12, 10 );
+ }
+}
diff --git a/includes/class-cepis-woo-transport.php b/includes/class-cepis-woo-transport.php
new file mode 100644
index 0000000..888be54
--- /dev/null
+++ b/includes/class-cepis-woo-transport.php
@@ -0,0 +1,58 @@
+<?php
+/**
+ * The bundled PHP client, taught to speak through the WordPress HTTP API.
+ *
+ * @package CepisWooCommerce
+ */
+
+declare(strict_types=1);
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * A `Cepis\Transport` over {@see wp_remote_get()}.
+ *
+ * The library defaults to talking to curl directly, which is the wrong thing
+ * to do inside WordPress: a site's proxy configuration, its
+ * `http_request_args` filters, its request timeouts and everything a host adds
+ * on top of the HTTP API would all be bypassed. Routing through
+ * {@see wp_remote_get()} keeps this plugin a well-behaved guest.
+ *
+ * Exactly one request per call, no retrying — retries, backoff and
+ * `Retry-After` stay the client's job.
+ */
+final class Cepis_Woo_Transport implements Cepis\Transport {
+
+ /**
+ * Perform a single GET.
+ *
+ * @param string $url Absolute URL, query string included.
+ * @param array<string, string> $headers Request headers as name => value.
+ * @param float $timeout Seconds to allow for this attempt.
+ *
+ * @throws Cepis\NetworkException The request never completed.
+ */
+ public function get( string $url, array $headers, float $timeout ): Cepis\Response {
+ $response = wp_remote_get(
+ $url,
+ array(
+ 'headers' => $headers,
+ 'timeout' => $timeout,
+ 'redirection' => 3,
+ 'sslverify' => true,
+ )
+ );
+
+ if ( is_wp_error( $response ) ) {
+ throw new Cepis\NetworkException( esc_html( $response->get_error_message() ) );
+ }
+
+ $response_headers = wp_remote_retrieve_headers( $response );
+
+ return new Cepis\Response(
+ (int) wp_remote_retrieve_response_code( $response ),
+ is_object( $response_headers ) ? $response_headers->getAll() : (array) $response_headers,
+ (string) wp_remote_retrieve_body( $response )
+ );
+ }
+}