diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 16:41:48 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 16:47:14 -0300 |
| commit | 535dbda6b48171f8115b455e54790de94d97d42a (patch) | |
| tree | 5c0410011e61a883bdf063ee612875a637fb1d22 /includes | |
| download | woocommerce-29190494c82855f0f0ce7fb16f831c8f971022c5.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'includes')
| -rw-r--r-- | includes/class-cepis-woo-admin.php | 74 | ||||
| -rw-r--r-- | includes/class-cepis-woo-assets.php | 183 | ||||
| -rw-r--r-- | includes/class-cepis-woo-lookup.php | 170 | ||||
| -rw-r--r-- | includes/class-cepis-woo-messages.php | 60 | ||||
| -rw-r--r-- | includes/class-cepis-woo-options.php | 105 | ||||
| -rw-r--r-- | includes/class-cepis-woo-rest.php | 120 | ||||
| -rw-r--r-- | includes/class-cepis-woo-settings.php | 135 | ||||
| -rw-r--r-- | includes/class-cepis-woo-token.php | 76 | ||||
| -rw-r--r-- | includes/class-cepis-woo-transport.php | 58 |
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 ) + ); + } +} |