From 535dbda6b48171f8115b455e54790de94d97d42a Mon Sep 17 00:00:00 2001 From: Murilo Ijanc Date: Sun, 26 Jul 2026 16:41:48 -0300 Subject: Initial import --- includes/class-cepis-woo-admin.php | 74 +++++++++++++ includes/class-cepis-woo-assets.php | 183 +++++++++++++++++++++++++++++++++ includes/class-cepis-woo-lookup.php | 170 ++++++++++++++++++++++++++++++ includes/class-cepis-woo-messages.php | 60 +++++++++++ includes/class-cepis-woo-options.php | 105 +++++++++++++++++++ includes/class-cepis-woo-rest.php | 120 +++++++++++++++++++++ includes/class-cepis-woo-settings.php | 135 ++++++++++++++++++++++++ includes/class-cepis-woo-token.php | 76 ++++++++++++++ includes/class-cepis-woo-transport.php | 58 +++++++++++ 9 files changed, 981 insertions(+) create mode 100644 includes/class-cepis-woo-admin.php create mode 100644 includes/class-cepis-woo-assets.php create mode 100644 includes/class-cepis-woo-lookup.php create mode 100644 includes/class-cepis-woo-messages.php create mode 100644 includes/class-cepis-woo-options.php create mode 100644 includes/class-cepis-woo-rest.php create mode 100644 includes/class-cepis-woo-settings.php create mode 100644 includes/class-cepis-woo-token.php create mode 100644 includes/class-cepis-woo-transport.php (limited to 'includes') 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 @@ + $pages Settings pages WooCommerce has collected. + * + * @return array + */ + 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 $links Existing action links. + * + * @return array + */ + 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( + '%s', + 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 @@ + + */ + 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> + */ + 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> $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 + */ + 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 $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 @@ +, 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, 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 @@ + 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 $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 @@ +[A-Za-z0-9]{1,64})/(?P[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 @@ +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> + */ + 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 @@ + $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 ) + ); + } +} -- cgit v1.3