From 6ead99eeb80dc187bae20640a7291b49c7cda56f Mon Sep 17 00:00:00 2001 From: Vansh Kapoor Date: Tue, 9 Jun 2026 16:08:55 +0530 Subject: [PATCH 01/25] Phone: reliable per-visitor auto country detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect the visitor's country server-side and apply it to the phone field: - Helper::get_geo_country(): prefer a CDN/server country header (Cloudflare/CloudFront/mod_geoip) — free, instant, cache-safe and independent of the connecting IP — before any API call; then ipapi.co with an optional filterable key (srfm_ipapi_api_key) for reliable IP detection; degrade to the field's configured default country. - Reject private/reserved IPs, detect ipapi 200-with-error rate-limit bodies, short failure-cache TTL (srfm_geo_failure_ttl) so a blip self-heals, and a site-wide hourly cap on outbound calls. - Public REST route sureforms/v1/geo-country so detection is per-visitor and correct on full-page-cached sites (not baked into cached HTML). - Frontend applyAutoCountry() fetches the endpoint and applies the country via intl-tel-input, cached in sessionStorage per session. - phone-markup.php emits data-auto-country and passes the configured default as the fallback; frontend-assets.php localizes the endpoint URL. --- assets/js/unminified/blocks/phone.js | 85 +++++++++++++ inc/fields/phone-markup.php | 104 ++------------- inc/frontend-assets.php | 12 ++ inc/helper.php | 184 +++++++++++++++++++++++++++ inc/rest-api.php | 26 ++++ 5 files changed, 316 insertions(+), 95 deletions(-) diff --git a/assets/js/unminified/blocks/phone.js b/assets/js/unminified/blocks/phone.js index 7f4715283..ec433a8c8 100644 --- a/assets/js/unminified/blocks/phone.js +++ b/assets/js/unminified/blocks/phone.js @@ -43,6 +43,77 @@ function validateCountryWithFilters( return country; } +/** + * Apply per-visitor auto country detection to an intl-tel-input instance. + * + * Fetches the visitor's country from the same-origin geo-country REST endpoint + * and applies it via setCountry(), so auto-detection is correct per visitor even + * when the page HTML is full-page cached. Respects the field's country filters + * and never overrides a value the visitor already started typing. The result is + * cached in sessionStorage to avoid repeat requests within a session. + * + * @param {Object} iti The intl-tel-input instance. + * @param {HTMLElement} phoneNumber The phone input element. + * @param {Object} filters Country-filter settings for this field. + */ +function applyAutoCountry( iti, phoneNumber, filters ) { + const endpoint = + window.srfm_phone_data && window.srfm_phone_data.geo_endpoint; + if ( ! endpoint ) { + return; + } + + const setCountry = ( country ) => { + if ( ! country || typeof country !== 'string' ) { + return; + } + // Don't override a value the visitor already started entering. + if ( phoneNumber.value && phoneNumber.value.trim() !== '' ) { + return; + } + const validated = validateCountryWithFilters( + country.toLowerCase(), + filters.enableCountryFilter, + filters.countryFilterType, + filters.includeCountries, + filters.excludeCountries + ); + if ( validated ) { + iti.setCountry( validated ); + } + }; + + // Reuse a cached lookup for the session to avoid repeat requests. + let cached = null; + try { + cached = window.sessionStorage?.getItem( 'srfm_geo_country' ); + } catch { + cached = null; + } + if ( cached ) { + setCountry( cached ); + return; + } + + fetch( endpoint, { headers: { Accept: 'application/json' } } ) + .then( ( res ) => ( res.ok ? res.json() : null ) ) + .then( ( data ) => { + const country = data && data.country ? String( data.country ) : ''; + if ( ! country ) { + return; + } + try { + window.sessionStorage?.setItem( 'srfm_geo_country', country ); + } catch { + // sessionStorage may be unavailable (e.g. private mode) — ignore. + } + setCountry( country ); + } ) + .catch( () => { + // Network error — keep the server-provided default-country. + } ); +} + function initializePhoneField() { const phone = document.querySelectorAll( '.srfm-phone-block' ); @@ -146,6 +217,20 @@ function initializePhoneField() { // Dropdown selection goes through _selectListItem -> _setCountry directly, so it still works. iti._updateCountryFromNumber = () => false; + // Per-visitor auto country detection. The server bakes an initial + // `default-country`, but on full-page-cached sites that value is the first + // visitor's. When auto-country is enabled, fetch the current visitor's + // country from the same-origin REST endpoint (not part of the cached page + // HTML) and apply it — correct per visitor. + if ( phoneNumber.getAttribute( 'data-auto-country' ) === 'true' ) { + applyAutoCountry( iti, phoneNumber, { + enableCountryFilter, + countryFilterType, + includeCountries, + excludeCountries, + } ); + } + const countriesData = iti?.countryList.querySelectorAll( '.iti__country' ); diff --git a/inc/fields/phone-markup.php b/inc/fields/phone-markup.php index fe166502c..4b6f1bee0 100644 --- a/inc/fields/phone-markup.php +++ b/inc/fields/phone-markup.php @@ -103,7 +103,12 @@ public function __construct( $attributes ) { // Performance: The API is called only once per visitor IP and cached in a transient // for 24 hours — subsequent page loads for the same IP resolve instantly from cache. if ( $this->auto_country ) { - $this->default_country = $this->get_geo_country(); + // Pass the configured default as the fallback so a detection failure + // degrades to the user's chosen country instead of a hardcoded 'us'. + $fallback = ! empty( $this->default_country ) && is_string( $this->default_country ) + ? strtolower( $this->default_country ) + : 'us'; + $this->default_country = Helper::get_geo_country( $fallback ); } $this->set_unique_slug(); $this->set_field_name( $this->unique_slug ); @@ -132,6 +137,9 @@ class="srfm-input-common srfm-input-slug ); ?>" data-required="data_require_attr ); ?>" aria-required="data_require_attr ); ?>" default-country="default_country ); ?>" + auto_country ) { ?> + data-auto-country="true" + enable_country_filter ) { ?> data-enable-country-filter="true" data-country-filter-type="country_filter_type ); ?>" @@ -153,98 +161,4 @@ class="srfm-input-common srfm-input-slug ); ?>" return ob_get_clean(); } - /** - * Detect the visitor's 2-letter country code via server-side IP geolocation. - * - * Calls ipapi.co once per visitor IP and caches the result in a transient for - * 24 hours so subsequent page loads resolve instantly without any API call. - * - * Failure responses (network error, non-200, malformed body, invalid country code) - * are also cached as 'us' for 1 hour to prevent a thundering-herd retry storm - * if ipapi.co goes down or rate-limits us. - * - * Private/reserved IPs (e.g., 10.x, 192.168.x, 127.0.0.1, ::1) are rejected up - * front — ipapi.co cannot geolocate them, and accepting them would let spoofed - * X-Forwarded-For headers flood the transient cache. - * - * A site-wide hourly cap (default 40, filterable via `srfm_geo_api_hourly_cap`) - * bounds outbound calls so a determined attacker can't exhaust the ipapi free-tier - * quota (1,000/day) by rotating spoofed public IPs. - * - * Note on page caching: When a full-page cache plugin is active, the HTML - * (including default-country) is served from cache. The first visitor's country - * is baked into the cached page. This is an acceptable tradeoff — the alternative - * (client-side fetch) caused CORS failures and 429 rate limits. Sites needing - * per-visitor precision can set a specific default country per field. - * - * @since 2.8.0 - * @return string Lowercase 2-letter country code, defaults to 'us'. - */ - private function get_geo_country() { - $ip = Helper::get_visitor_ip(); - if ( empty( $ip ) ) { - return 'us'; - } - - // Reject private/reserved IPs: ipapi.co returns "Reserved IP Address" for them, - // and accepting them would let spoofed X-Forwarded-For headers flood the cache. - if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { - return 'us'; - } - - $cache_key = 'srfm_geo_' . md5( $ip ); - $cached = get_transient( $cache_key ); - if ( is_string( $cached ) && '' !== $cached ) { - return $cached; - } - - // Site-wide hourly cap on outbound ipapi calls. The counter rolls over every hour - // (key includes YmdH) so we never need to explicitly reset it. Default 40 stays - // well under ipapi's 1,000/day free tier; paid-tier sites can raise via filter. - $quota_key = 'srfm_geo_quota_' . gmdate( 'YmdH' ); - $quota_cap = Helper::get_integer_value( apply_filters( 'srfm_geo_api_hourly_cap', 40 ) ); - $count = Helper::get_integer_value( get_transient( $quota_key ) ); - if ( $count >= $quota_cap ) { - set_transient( $cache_key, 'us', HOUR_IN_SECONDS ); - return 'us'; - } - set_transient( $quota_key, $count + 1, HOUR_IN_SECONDS ); - - // ipapi.co's /json/ endpoint geolocates the *caller's* IP. Since this request - // originates from the WordPress server (not the visitor's browser), we must - // pass the visitor's IP explicitly via /{ip}/json/ — otherwise ipapi.co - // returns the hosting datacenter's country for every visitor. - $url = 'https://ipapi.co/' . rawurlencode( $ip ) . '/json/'; - $response = wp_remote_get( - $url, - [ - 'timeout' => 3, - 'user-agent' => 'SureForms/' . SRFM_VER . ' (+https://sureforms.com)', - ] - ); - - if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { - set_transient( $cache_key, 'us', HOUR_IN_SECONDS ); - return 'us'; - } - - $body = json_decode( wp_remote_retrieve_body( $response ), true ); - - if ( ! is_array( $body ) || empty( $body['country_code'] ) || ! is_string( $body['country_code'] ) ) { - set_transient( $cache_key, 'us', HOUR_IN_SECONDS ); - return 'us'; - } - - $country = strtolower( $body['country_code'] ); - - // Validate the external API response is a valid 2-letter country code. - if ( ! preg_match( '/^[a-z]{2}$/', $country ) ) { - set_transient( $cache_key, 'us', HOUR_IN_SECONDS ); - return 'us'; - } - - set_transient( $cache_key, $country, DAY_IN_SECONDS ); - - return $country; - } } diff --git a/inc/frontend-assets.php b/inc/frontend-assets.php index e7ced4e17..5548e52b6 100644 --- a/inc/frontend-assets.php +++ b/inc/frontend-assets.php @@ -258,6 +258,18 @@ public function enqueue_srfm_script( $block_type, $attr ) { $block_dependencies = [ SRFM_SLUG . "-{$block_name}-intl-input-deps" ]; } wp_enqueue_script( SRFM_SLUG . "-{$block_name}", $js_uri . $block_name . $file_prefix . '.js', $block_dependencies, SRFM_VER, true ); + + if ( 'phone' === $block_name ) { + // Geo-country endpoint for per-visitor auto country detection. + // Built with rest_url() so it is correct regardless of permalink structure. + wp_localize_script( + SRFM_SLUG . "-{$block_name}", + 'srfm_phone_data', + [ + 'geo_endpoint' => esc_url_raw( rest_url( 'sureforms/v1/geo-country' ) ), + ] + ); + } } if ( 'input' === $block_name && isset( $attr['inputMask'] ) && 'none' !== $attr['inputMask'] ) { diff --git a/inc/helper.php b/inc/helper.php index 0d234d647..06c452adf 100644 --- a/inc/helper.php +++ b/inc/helper.php @@ -2434,4 +2434,188 @@ public static function get_visitor_ip() { return ''; } + /** + * Read a visitor country code from a CDN / server geo header, if present. + * + * Many hosts sit behind Cloudflare, CloudFront or a geo-aware web server that + * injects the visitor's country as a request header. This is free, instant, + * per-visitor and works on full-page-cached sites, so we prefer it over an + * outbound API call. Returns '' when no usable header is present. + * + * @since x.x.x + * @return string Lowercase 2-letter country code, or '' when unavailable. + */ + private static function get_cdn_country() { + $headers = [ + 'HTTP_CF_IPCOUNTRY', // Cloudflare. + 'HTTP_CLOUDFRONT_VIEWER_COUNTRY', // AWS CloudFront. + 'GEOIP_COUNTRY_CODE', // Apache/Nginx mod_geoip / MaxMind. + 'HTTP_X_GEO_COUNTRY', // Some CDNs / reverse proxies. + 'HTTP_X_COUNTRY_CODE', // Some CDNs. + ]; + + foreach ( $headers as $header ) { + if ( empty( $_SERVER[ $header ] ) ) { + continue; + } + + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Validated by the regex below. + $code = strtolower( trim( sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ) ) ); + + // Cloudflare sends 'xx' for unknown and 't1' for Tor; reject non-ISO values. + if ( preg_match( '/^[a-z]{2}$/', $code ) && 'xx' !== $code && 't1' !== $code ) { + return $code; + } + } + + /** + * Filters the CDN-derived visitor country code, before the ipapi.co fallback. + * + * Lets sites short-circuit detection with a server-side country code (e.g. + * from a custom header) without any outbound API call. + * + * @since x.x.x + * + * @param string $code Lowercase 2-letter country code, or '' if none found. + */ + return apply_filters( 'srfm_cdn_country', '' ); + } + + /** + * TTL (in seconds) for caching a failed geo lookup. + * + * Short by default so a transient blip (rate-limit, timeout) self-heals on the + * next visit instead of pinning the fallback country for a full hour, while + * still preventing per-request retry storms. Filterable via `srfm_geo_failure_ttl`. + * + * @since x.x.x + * @return int + */ + private static function get_geo_failure_ttl() { + return self::get_integer_value( apply_filters( 'srfm_geo_failure_ttl', 5 * MINUTE_IN_SECONDS ) ); + } + + /** + * Detect the visitor's 2-letter country code via server-side IP geolocation. + * + * Prefers a CDN/server-provided country header (Cloudflare, CloudFront, mod_geoip) + * when present — free, instant and cache-safe. Otherwise calls ipapi.co once per + * visitor IP and caches the result in a transient for 24 hours so subsequent + * lookups for the same IP resolve instantly. Failures are cached for a short TTL + * (see get_geo_failure_ttl()) to avoid retry storms while still self-healing, and + * a site-wide hourly cap (filterable via `srfm_geo_api_hourly_cap`, default 40) + * bounds outbound calls. Private/reserved IPs are rejected up front. + * + * Intended to be called per-visitor (e.g. via the geo-country REST route) so + * the result is correct on full-page-cached sites instead of being baked into + * the cached HTML. + * + * Local testing: private/loopback IPs (e.g. 127.0.0.1) cannot be geolocated, so + * inject a public IP via the `srfm_visitor_ip` filter to exercise detection: + * + * add_filter( 'srfm_visitor_ip', static fn() => '8.8.8.8' ); // US; try 1.1.1.1 etc. + * + * @param string $fallback Country code returned when detection is unavailable. + * @since x.x.x + * @return string Lowercase 2-letter country code. + */ + public static function get_geo_country( $fallback = 'us' ) { + // Prefer a CDN/server-provided country header — free, instant, per-visitor + // and cache-safe. It is independent of the connecting IP (it still resolves + // when the visitor IP is private/loopback, e.g. local dev or behind a + // proxy), so it must be checked before the IP-based path below. + $cdn_country = self::get_cdn_country(); + if ( '' !== $cdn_country ) { + return $cdn_country; + } + + $ip = self::get_visitor_ip(); + if ( empty( $ip ) ) { + return $fallback; + } + + // Reject private/reserved IPs: ipapi.co cannot geolocate them, and accepting + // them would let spoofed X-Forwarded-For headers flood the transient cache. + if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { + return $fallback; + } + + $cache_key = 'srfm_geo_' . md5( $ip ); + $cached = get_transient( $cache_key ); + if ( is_string( $cached ) && '' !== $cached ) { + return $cached; + } + + // Site-wide hourly cap on outbound ipapi calls; the counter rolls over + // every hour (key includes YmdH) so it never needs an explicit reset. + $quota_key = 'srfm_geo_quota_' . gmdate( 'YmdH' ); + $quota_cap = self::get_integer_value( apply_filters( 'srfm_geo_api_hourly_cap', 40 ) ); + $count = self::get_integer_value( get_transient( $quota_key ) ); + if ( $count >= $quota_cap ) { + self::srfm_log( $quota_cap, 'SRFM geo lookup skipped (hourly cap reached):' ); + set_transient( $cache_key, $fallback, self::get_geo_failure_ttl() ); + return $fallback; + } + set_transient( $quota_key, $count + 1, HOUR_IN_SECONDS ); + + // Pass the visitor's IP explicitly via /{ip}/json/ — the request originates + // from the server, so the bare /json/ endpoint would return the host's country. + $url = 'https://ipapi.co/' . rawurlencode( $ip ) . '/json/'; + + // ipapi.co's free (keyless) tier is heavily rate-limited, so unauthenticated + // lookups are best-effort and often fail to the configured fallback. Sites + // that need reliable IP-based detection can supply a paid ipapi.co key via + // this filter; CDN-fronted sites resolve earlier via get_cdn_country() and + // never reach this call. + $api_key = self::get_string_value( apply_filters( 'srfm_ipapi_api_key', '' ) ); + if ( '' !== $api_key ) { + $url = add_query_arg( 'key', rawurlencode( $api_key ), $url ); + } + + $response = wp_remote_get( + $url, + [ + 'timeout' => 3, + 'user-agent' => 'SureForms/' . SRFM_VER . ' (+https://sureforms.com)', + ] + ); + + if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { + $detail = is_wp_error( $response ) ? $response->get_error_message() : wp_remote_retrieve_response_code( $response ); + self::srfm_log( $detail, 'SRFM geo lookup failed (transport):' ); + set_transient( $cache_key, $fallback, self::get_geo_failure_ttl() ); + return $fallback; + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + // ipapi.co's free tier returns HTTP 200 with a JSON error body + // (e.g. {"error":true,"reason":"RateLimited"}) when throttled — treat as a failure. + if ( is_array( $body ) && ! empty( $body['error'] ) ) { + $reason = ! empty( $body['reason'] ) && is_string( $body['reason'] ) ? $body['reason'] : 'unknown'; + self::srfm_log( $reason, 'SRFM geo lookup failed (ipapi error):' ); + set_transient( $cache_key, $fallback, self::get_geo_failure_ttl() ); + return $fallback; + } + + if ( ! is_array( $body ) || empty( $body['country_code'] ) || ! is_string( $body['country_code'] ) ) { + self::srfm_log( wp_remote_retrieve_response_code( $response ), 'SRFM geo lookup failed (no country_code):' ); + set_transient( $cache_key, $fallback, self::get_geo_failure_ttl() ); + return $fallback; + } + + $country = strtolower( $body['country_code'] ); + + // Validate the external API response is a valid 2-letter country code. + if ( ! preg_match( '/^[a-z]{2}$/', $country ) ) { + self::srfm_log( $country, 'SRFM geo lookup failed (invalid country code):' ); + set_transient( $cache_key, $fallback, self::get_geo_failure_ttl() ); + return $fallback; + } + + set_transient( $cache_key, $country, DAY_IN_SECONDS ); + + return $country; + } + } diff --git a/inc/rest-api.php b/inc/rest-api.php index 43df5c0e0..5ed1a8663 100644 --- a/inc/rest-api.php +++ b/inc/rest-api.php @@ -84,6 +84,24 @@ public function sanitize_boolean_field( $value ) { return filter_var( $value, FILTER_VALIDATE_BOOLEAN ); } + /** + * Return the visitor's detected country code. + * + * Public, read-only — resolves the country for the *current* request's IP via + * Helper::get_geo_country(), so the phone field can fetch it per-visitor and + * work on full-page-cached sites. Outbound geolocation calls are bounded by + * the hourly cap inside Helper::get_geo_country(). + * + * @since x.x.x + * @return \WP_REST_Response + */ + public function get_geo_country() { + return new \WP_REST_Response( + [ 'country' => Helper::get_geo_country() ], + 200 + ); + } + /** * Get the data for generating entries chart. * @@ -1385,6 +1403,14 @@ private function get_endpoints() { 'callback' => [ AI_Auth::get_instance(), 'handle_access_key' ], 'permission_callback' => [ Helper::class, 'get_items_permissions_check' ], ], + // Public route: returns the visitor's detected country code. Called + // per-visitor from the phone field so auto-country detection works + // on full-page-cached sites (the value isn't baked into cached HTML). + 'geo-country' => [ + 'methods' => 'GET', + 'callback' => [ $this, 'get_geo_country' ], + 'permission_callback' => '__return_true', + ], // This route is to get the form submissions for the last 30 days. 'entries-chart-data' => [ 'methods' => 'GET', From a18bfe671ee365b62734cb0d716276c768301437 Mon Sep 17 00:00:00 2001 From: Vansh Kapoor Date: Tue, 9 Jun 2026 16:34:30 +0530 Subject: [PATCH 02/25] Phone: add on-device Intl country fallback for auto-detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-side IP detection can't work on localhost (loopback) and ipapi.co's keyless tier is rate-limited, so auto-country fell back to US. Add a layered, privacy-safe fallback: - REST geo-country endpoint now returns { country, detected }; detected is false when the server has no confident result (no CDN header, no successful IP lookup). - Frontend detectCountryFromBrowser() derives the ISO region from the browser locale via Intl.Locale — purely on-device, no network/IP/third-party, so it works offline, on localhost and on full-page-cached pages (none of the CORS/rate-limit/privacy issues that moved the IP lookup server-side). - applyAutoCountry() applies the local guess immediately and only lets a confident server result override it; sessionStorage key bumped so stale fallback values don't mask the local guess. --- assets/js/unminified/blocks/phone.js | 74 +++++++++++++++++++++------- inc/rest-api.php | 10 +++- 2 files changed, 66 insertions(+), 18 deletions(-) diff --git a/assets/js/unminified/blocks/phone.js b/assets/js/unminified/blocks/phone.js index ec433a8c8..64b3b440a 100644 --- a/assets/js/unminified/blocks/phone.js +++ b/assets/js/unminified/blocks/phone.js @@ -43,26 +43,55 @@ function validateCountryWithFilters( return country; } +/** + * Detect a likely country for the visitor using only on-device browser data. + * + * Reads the ISO region from the browser's locale(s) via Intl — no network + * request, no IP, no third-party call — so it works offline, on localhost and + * on full-page-cached pages, and never raises the CORS / rate-limit / privacy + * concerns that a browser geo-IP request would. + * + * @return {string} Lowercase 2-letter country code, or '' when undeterminable. + */ +function detectCountryFromBrowser() { + try { + const locales = + Array.isArray( navigator.languages ) && navigator.languages.length + ? navigator.languages + : [ navigator.language ]; + for ( const locale of locales ) { + if ( ! locale ) { + continue; + } + const region = new Intl.Locale( locale ).region; + if ( region && /^[A-Z]{2}$/.test( region ) ) { + return region.toLowerCase(); + } + } + } catch { + // Intl.Locale unsupported or a malformed locale — fall through. + } + return ''; +} + /** * Apply per-visitor auto country detection to an intl-tel-input instance. * - * Fetches the visitor's country from the same-origin geo-country REST endpoint - * and applies it via setCountry(), so auto-detection is correct per visitor even - * when the page HTML is full-page cached. Respects the field's country filters - * and never overrides a value the visitor already started typing. The result is - * cached in sessionStorage to avoid repeat requests within a session. + * Resolution order, most-to-least authoritative: + * 1. A browser-local Intl guess applied immediately — works offline, on + * localhost and on full-page-cached pages, with no flash and no network. + * 2. The same-origin geo-country REST endpoint, used to refine the guess only + * when the server *confidently* detected (CDN header or a successful IP + * lookup); cached in sessionStorage for the session. + * + * Respects the field's country filters and never overrides a value the visitor + * already started typing. * * @param {Object} iti The intl-tel-input instance. * @param {HTMLElement} phoneNumber The phone input element. * @param {Object} filters Country-filter settings for this field. */ function applyAutoCountry( iti, phoneNumber, filters ) { - const endpoint = - window.srfm_phone_data && window.srfm_phone_data.geo_endpoint; - if ( ! endpoint ) { - return; - } - const setCountry = ( country ) => { if ( ! country || typeof country !== 'string' ) { return; @@ -83,10 +112,20 @@ function applyAutoCountry( iti, phoneNumber, filters ) { } }; - // Reuse a cached lookup for the session to avoid repeat requests. + // 1. Immediate, network-free local guess so the flag is sensible even on + // localhost, offline, or before any request resolves. + setCountry( detectCountryFromBrowser() ); + + const endpoint = + window.srfm_phone_data && window.srfm_phone_data.geo_endpoint; + if ( ! endpoint ) { + return; + } + + // 2. Prefer a confident server detection. Reuse a cached one for the session. let cached = null; try { - cached = window.sessionStorage?.getItem( 'srfm_geo_country' ); + cached = window.sessionStorage?.getItem( 'srfm_geo_country_detected' ); } catch { cached = null; } @@ -98,19 +137,20 @@ function applyAutoCountry( iti, phoneNumber, filters ) { fetch( endpoint, { headers: { Accept: 'application/json' } } ) .then( ( res ) => ( res.ok ? res.json() : null ) ) .then( ( data ) => { - const country = data && data.country ? String( data.country ) : ''; - if ( ! country ) { + // Only override the local guess when the server is confident. + if ( ! data || ! data.detected || ! data.country ) { return; } + const country = String( data.country ); try { - window.sessionStorage?.setItem( 'srfm_geo_country', country ); + window.sessionStorage?.setItem( 'srfm_geo_country_detected', country ); } catch { // sessionStorage may be unavailable (e.g. private mode) — ignore. } setCountry( country ); } ) .catch( () => { - // Network error — keep the server-provided default-country. + // Network error — keep the local guess. } ); } diff --git a/inc/rest-api.php b/inc/rest-api.php index 5ed1a8663..a9699afe3 100644 --- a/inc/rest-api.php +++ b/inc/rest-api.php @@ -96,8 +96,16 @@ public function sanitize_boolean_field( $value ) { * @return \WP_REST_Response */ public function get_geo_country() { + // Pass an empty fallback so '' unambiguously means "not confidently + // detected" (no CDN header and no successful IP lookup). The frontend then + // falls back to a privacy-safe, network-free Intl guess in the browser. + $country = Helper::get_geo_country( '' ); + return new \WP_REST_Response( - [ 'country' => Helper::get_geo_country() ], + [ + 'country' => $country, + 'detected' => '' !== $country, + ], 200 ); } From d31ed4863351a77b65a7831f3fcf1ea676a5e2f5 Mon Sep 17 00:00:00 2001 From: Vansh Kapoor Date: Tue, 9 Jun 2026 16:44:16 +0530 Subject: [PATCH 03/25] Phone: use device timezone (not language) for on-device country guess Intl.Locale region reflects the browser *language*, not physical location, so an en-US browser in India guessed US. Make the device timezone the primary on-device signal via an IANA timezone -> country map (e.g. Asia/Kolkata -> in), falling back to the locale region only when the timezone is unknown. Still purely on-device: no network, no IP, no third party. --- assets/js/unminified/blocks/phone.js | 48 +++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/assets/js/unminified/blocks/phone.js b/assets/js/unminified/blocks/phone.js index 64b3b440a..e8013594e 100644 --- a/assets/js/unminified/blocks/phone.js +++ b/assets/js/unminified/blocks/phone.js @@ -43,17 +43,57 @@ function validateCountryWithFilters( return country; } +/** + * IANA timezone → ISO 3166-1 alpha-2 country (lowercase). + * + * The device timezone reflects the visitor's physical location far better than + * their browser language, so it is the primary on-device signal. Covers the + * common canonical zones; anything unlisted falls back to the locale region. + */ +const TIMEZONE_TO_COUNTRY = { + // North America. + 'America/New_York': 'us', 'America/Detroit': 'us', 'America/Chicago': 'us', 'America/Denver': 'us', 'America/Phoenix': 'us', 'America/Los_Angeles': 'us', 'America/Anchorage': 'us', 'America/Adak': 'us', 'America/Boise': 'us', 'America/Indiana/Indianapolis': 'us', 'Pacific/Honolulu': 'us', + 'America/Toronto': 'ca', 'America/Vancouver': 'ca', 'America/Edmonton': 'ca', 'America/Winnipeg': 'ca', 'America/Halifax': 'ca', 'America/St_Johns': 'ca', 'America/Regina': 'ca', + 'America/Mexico_City': 'mx', 'America/Tijuana': 'mx', 'America/Monterrey': 'mx', 'America/Cancun': 'mx', + // Central & South America. + 'America/Bogota': 'co', 'America/Lima': 'pe', 'America/Caracas': 've', 'America/Santiago': 'cl', 'America/Argentina/Buenos_Aires': 'ar', 'America/Sao_Paulo': 'br', 'America/Bahia': 'br', 'America/Fortaleza': 'br', 'America/Manaus': 'br', 'America/Montevideo': 'uy', 'America/Asuncion': 'py', 'America/La_Paz': 'bo', 'America/Guayaquil': 'ec', 'America/Panama': 'pa', 'America/Costa_Rica': 'cr', 'America/Guatemala': 'gt', 'America/El_Salvador': 'sv', 'America/Tegucigalpa': 'hn', 'America/Managua': 'ni', 'America/Santo_Domingo': 'do', 'America/Havana': 'cu', 'America/Jamaica': 'jm', 'America/Puerto_Rico': 'pr', 'America/Port_of_Spain': 'tt', + // Europe. + 'Europe/London': 'gb', 'Europe/Dublin': 'ie', 'Europe/Lisbon': 'pt', 'Atlantic/Canary': 'es', 'Europe/Madrid': 'es', 'Europe/Paris': 'fr', 'Europe/Brussels': 'be', 'Europe/Amsterdam': 'nl', 'Europe/Luxembourg': 'lu', 'Europe/Berlin': 'de', 'Europe/Zurich': 'ch', 'Europe/Vienna': 'at', 'Europe/Rome': 'it', 'Europe/Malta': 'mt', 'Europe/Copenhagen': 'dk', 'Europe/Oslo': 'no', 'Europe/Stockholm': 'se', 'Europe/Helsinki': 'fi', 'Europe/Reykjavik': 'is', 'Europe/Warsaw': 'pl', 'Europe/Prague': 'cz', 'Europe/Bratislava': 'sk', 'Europe/Budapest': 'hu', 'Europe/Vilnius': 'lt', 'Europe/Riga': 'lv', 'Europe/Tallinn': 'ee', 'Europe/Athens': 'gr', 'Europe/Bucharest': 'ro', 'Europe/Sofia': 'bg', 'Europe/Belgrade': 'rs', 'Europe/Zagreb': 'hr', 'Europe/Ljubljana': 'si', 'Europe/Sarajevo': 'ba', 'Europe/Skopje': 'mk', 'Europe/Tirane': 'al', 'Europe/Chisinau': 'md', 'Europe/Kyiv': 'ua', 'Europe/Kiev': 'ua', 'Europe/Minsk': 'by', 'Europe/Moscow': 'ru', 'Europe/Istanbul': 'tr', + // Middle East. + 'Asia/Jerusalem': 'il', 'Asia/Beirut': 'lb', 'Asia/Amman': 'jo', 'Asia/Damascus': 'sy', 'Asia/Baghdad': 'iq', 'Asia/Riyadh': 'sa', 'Asia/Kuwait': 'kw', 'Asia/Qatar': 'qa', 'Asia/Dubai': 'ae', 'Asia/Muscat': 'om', 'Asia/Bahrain': 'bh', 'Asia/Tehran': 'ir', + // South & Central Asia. + 'Asia/Kabul': 'af', 'Asia/Karachi': 'pk', 'Asia/Kolkata': 'in', 'Asia/Calcutta': 'in', 'Asia/Colombo': 'lk', 'Asia/Kathmandu': 'np', 'Asia/Dhaka': 'bd', 'Asia/Thimphu': 'bt', 'Asia/Almaty': 'kz', 'Asia/Tashkent': 'uz', 'Asia/Baku': 'az', 'Asia/Yerevan': 'am', 'Asia/Tbilisi': 'ge', + // East & Southeast Asia. + 'Asia/Yangon': 'mm', 'Asia/Bangkok': 'th', 'Asia/Ho_Chi_Minh': 'vn', 'Asia/Phnom_Penh': 'kh', 'Asia/Vientiane': 'la', 'Asia/Jakarta': 'id', 'Asia/Makassar': 'id', 'Asia/Kuala_Lumpur': 'my', 'Asia/Singapore': 'sg', 'Asia/Manila': 'ph', 'Asia/Hong_Kong': 'hk', 'Asia/Macau': 'mo', 'Asia/Taipei': 'tw', 'Asia/Shanghai': 'cn', 'Asia/Urumqi': 'cn', 'Asia/Tokyo': 'jp', 'Asia/Seoul': 'kr', 'Asia/Ulaanbaatar': 'mn', + // Africa. + 'Africa/Abidjan': 'ci', 'Africa/Accra': 'gh', 'Africa/Addis_Ababa': 'et', 'Africa/Algiers': 'dz', 'Africa/Cairo': 'eg', 'Africa/Casablanca': 'ma', 'Africa/Johannesburg': 'za', 'Africa/Lagos': 'ng', 'Africa/Nairobi': 'ke', 'Africa/Tripoli': 'ly', 'Africa/Tunis': 'tn', 'Africa/Khartoum': 'sd', 'Africa/Dar_es_Salaam': 'tz', 'Africa/Kampala': 'ug', 'Africa/Kinshasa': 'cd', 'Africa/Maputo': 'mz', 'Africa/Windhoek': 'na', 'Africa/Harare': 'zw', 'Africa/Lusaka': 'zm', 'Africa/Dakar': 'sn', + // Oceania. + 'Australia/Sydney': 'au', 'Australia/Melbourne': 'au', 'Australia/Brisbane': 'au', 'Australia/Perth': 'au', 'Australia/Adelaide': 'au', 'Australia/Darwin': 'au', 'Australia/Hobart': 'au', 'Pacific/Auckland': 'nz', 'Pacific/Fiji': 'fj', 'Pacific/Guam': 'gu', 'Pacific/Port_Moresby': 'pg', +}; + /** * Detect a likely country for the visitor using only on-device browser data. * - * Reads the ISO region from the browser's locale(s) via Intl — no network - * request, no IP, no third-party call — so it works offline, on localhost and - * on full-page-cached pages, and never raises the CORS / rate-limit / privacy - * concerns that a browser geo-IP request would. + * Primary signal is the device timezone (physical location); the browser locale + * region (language) is a weaker secondary fallback. Both are read locally via + * Intl — no network request, no IP, no third-party call — so detection works + * offline, on localhost and on full-page-cached pages, and never raises the + * CORS / rate-limit / privacy concerns of a browser geo-IP request. * * @return {string} Lowercase 2-letter country code, or '' when undeterminable. */ function detectCountryFromBrowser() { + // 1. Timezone → country (reflects physical location). + try { + const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; + if ( tz && TIMEZONE_TO_COUNTRY[ tz ] ) { + return TIMEZONE_TO_COUNTRY[ tz ]; + } + } catch { + // Intl.DateTimeFormat unsupported — fall through to locale. + } + + // 2. Locale region (language) — weaker, but better than nothing. try { const locales = Array.isArray( navigator.languages ) && navigator.languages.length From a5b48f9d67236fb90ab050a9d99c1694d8efde6c Mon Sep 17 00:00:00 2001 From: Vansh Kapoor Date: Wed, 10 Jun 2026 14:21:54 +0530 Subject: [PATCH 04/25] Fix PHP Insights ordering and add geo country test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorder the new private static geo helpers after the public methods (PHP Insights OrderedClassElements), and cover the two flagged functions: Helper::get_geo_country() — CDN header preference with no outbound call, placeholder rejection with private-IP fallback, srfm_cdn_country filter short-circuit, API resolution with transient caching, and rate-limit error-body fallback with failure caching — and Rest_Api::get_geo_country() — detected and not-detected response shapes. --- inc/helper.php | 122 +++++++++++++------------- tests/unit/inc/test-helper.php | 141 +++++++++++++++++++++++++++++++ tests/unit/inc/test-rest-api.php | 56 ++++++++++++ 3 files changed, 258 insertions(+), 61 deletions(-) diff --git a/inc/helper.php b/inc/helper.php index 06c452adf..89a3c8649 100644 --- a/inc/helper.php +++ b/inc/helper.php @@ -2434,67 +2434,6 @@ public static function get_visitor_ip() { return ''; } - /** - * Read a visitor country code from a CDN / server geo header, if present. - * - * Many hosts sit behind Cloudflare, CloudFront or a geo-aware web server that - * injects the visitor's country as a request header. This is free, instant, - * per-visitor and works on full-page-cached sites, so we prefer it over an - * outbound API call. Returns '' when no usable header is present. - * - * @since x.x.x - * @return string Lowercase 2-letter country code, or '' when unavailable. - */ - private static function get_cdn_country() { - $headers = [ - 'HTTP_CF_IPCOUNTRY', // Cloudflare. - 'HTTP_CLOUDFRONT_VIEWER_COUNTRY', // AWS CloudFront. - 'GEOIP_COUNTRY_CODE', // Apache/Nginx mod_geoip / MaxMind. - 'HTTP_X_GEO_COUNTRY', // Some CDNs / reverse proxies. - 'HTTP_X_COUNTRY_CODE', // Some CDNs. - ]; - - foreach ( $headers as $header ) { - if ( empty( $_SERVER[ $header ] ) ) { - continue; - } - - // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Validated by the regex below. - $code = strtolower( trim( sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ) ) ); - - // Cloudflare sends 'xx' for unknown and 't1' for Tor; reject non-ISO values. - if ( preg_match( '/^[a-z]{2}$/', $code ) && 'xx' !== $code && 't1' !== $code ) { - return $code; - } - } - - /** - * Filters the CDN-derived visitor country code, before the ipapi.co fallback. - * - * Lets sites short-circuit detection with a server-side country code (e.g. - * from a custom header) without any outbound API call. - * - * @since x.x.x - * - * @param string $code Lowercase 2-letter country code, or '' if none found. - */ - return apply_filters( 'srfm_cdn_country', '' ); - } - - /** - * TTL (in seconds) for caching a failed geo lookup. - * - * Short by default so a transient blip (rate-limit, timeout) self-heals on the - * next visit instead of pinning the fallback country for a full hour, while - * still preventing per-request retry storms. Filterable via `srfm_geo_failure_ttl`. - * - * @since x.x.x - * @return int - */ - private static function get_geo_failure_ttl() { - return self::get_integer_value( apply_filters( 'srfm_geo_failure_ttl', 5 * MINUTE_IN_SECONDS ) ); - } - /** * Detect the visitor's 2-letter country code via server-side IP geolocation. * @@ -2618,4 +2557,65 @@ public static function get_geo_country( $fallback = 'us' ) { return $country; } + /** + * Read a visitor country code from a CDN / server geo header, if present. + * + * Many hosts sit behind Cloudflare, CloudFront or a geo-aware web server that + * injects the visitor's country as a request header. This is free, instant, + * per-visitor and works on full-page-cached sites, so we prefer it over an + * outbound API call. Returns '' when no usable header is present. + * + * @since x.x.x + * @return string Lowercase 2-letter country code, or '' when unavailable. + */ + private static function get_cdn_country() { + $headers = [ + 'HTTP_CF_IPCOUNTRY', // Cloudflare. + 'HTTP_CLOUDFRONT_VIEWER_COUNTRY', // AWS CloudFront. + 'GEOIP_COUNTRY_CODE', // Apache/Nginx mod_geoip / MaxMind. + 'HTTP_X_GEO_COUNTRY', // Some CDNs / reverse proxies. + 'HTTP_X_COUNTRY_CODE', // Some CDNs. + ]; + + foreach ( $headers as $header ) { + if ( empty( $_SERVER[ $header ] ) ) { + continue; + } + + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Validated by the regex below. + $code = strtolower( trim( sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ) ) ); + + // Cloudflare sends 'xx' for unknown and 't1' for Tor; reject non-ISO values. + if ( preg_match( '/^[a-z]{2}$/', $code ) && 'xx' !== $code && 't1' !== $code ) { + return $code; + } + } + + /** + * Filters the CDN-derived visitor country code, before the ipapi.co fallback. + * + * Lets sites short-circuit detection with a server-side country code (e.g. + * from a custom header) without any outbound API call. + * + * @since x.x.x + * + * @param string $code Lowercase 2-letter country code, or '' if none found. + */ + return apply_filters( 'srfm_cdn_country', '' ); + } + + /** + * TTL (in seconds) for caching a failed geo lookup. + * + * Short by default so a transient blip (rate-limit, timeout) self-heals on the + * next visit instead of pinning the fallback country for a full hour, while + * still preventing per-request retry storms. Filterable via `srfm_geo_failure_ttl`. + * + * @since x.x.x + * @return int + */ + private static function get_geo_failure_ttl() { + return self::get_integer_value( apply_filters( 'srfm_geo_failure_ttl', 5 * MINUTE_IN_SECONDS ) ); + } + } diff --git a/tests/unit/inc/test-helper.php b/tests/unit/inc/test-helper.php index 40b572b74..257bb5ed3 100644 --- a/tests/unit/inc/test-helper.php +++ b/tests/unit/inc/test-helper.php @@ -2638,6 +2638,133 @@ public function test_get_visitor_ip_reads_x_real_ip() { $this->assertEquals( '198.51.100.25', Helper::get_visitor_ip() ); } + // --- get_geo_country --- + + /** + * Test get_geo_country prefers a CDN-provided country header and never + * makes an outbound API call when one is present. + */ + public function test_get_geo_country_prefers_cdn_header() { + $this->clear_geo_server_vars(); + $_SERVER['HTTP_CF_IPCOUNTRY'] = 'DE'; + + $requests = 0; + $stub = static function () use ( &$requests ) { + $requests++; + return new \WP_Error( 'unexpected', 'No HTTP request expected.' ); + }; + add_filter( 'pre_http_request', $stub ); + + $this->assertSame( 'de', Helper::get_geo_country() ); + $this->assertSame( 0, $requests, 'A CDN header must short-circuit the API call.' ); + + remove_filter( 'pre_http_request', $stub ); + $this->clear_geo_server_vars(); + } + + /** + * Test get_geo_country rejects Cloudflare's 'XX' (unknown) placeholder and + * returns the fallback when the visitor IP is private/loopback. + */ + public function test_get_geo_country_rejects_cdn_placeholder_and_falls_back() { + $this->clear_geo_server_vars(); + $_SERVER['HTTP_CF_IPCOUNTRY'] = 'XX'; + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; // Private IP → no API call. + + $this->assertSame( 'us', Helper::get_geo_country() ); + $this->assertSame( 'in', Helper::get_geo_country( 'in' ) ); + + $this->clear_geo_server_vars(); + } + + /** + * Test the srfm_cdn_country filter can short-circuit detection before the + * ipapi.co fallback. + */ + public function test_get_geo_country_uses_cdn_country_filter() { + $this->clear_geo_server_vars(); + + $filter = static function () { + return 'fr'; + }; + add_filter( 'srfm_cdn_country', $filter ); + + $this->assertSame( 'fr', Helper::get_geo_country() ); + + remove_filter( 'srfm_cdn_country', $filter ); + $this->clear_geo_server_vars(); + } + + /** + * Test get_geo_country resolves the country via the IP API and caches the + * result in a transient so a second lookup makes no further request. + */ + public function test_get_geo_country_resolves_via_api_and_caches() { + $this->clear_geo_server_vars(); + $_SERVER['REMOTE_ADDR'] = '203.0.113.77'; + delete_transient( 'srfm_geo_' . md5( '203.0.113.77' ) ); + + $requests = 0; + $stub = static function () use ( &$requests ) { + $requests++; + return [ + 'headers' => [], + 'body' => (string) wp_json_encode( [ 'country_code' => 'IN' ] ), + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + ]; + }; + add_filter( 'pre_http_request', $stub ); + + $this->assertSame( 'in', Helper::get_geo_country() ); + $this->assertSame( 'in', Helper::get_geo_country(), 'Second lookup must be served from the transient cache.' ); + $this->assertSame( 1, $requests, 'Only the first lookup may hit the API.' ); + + remove_filter( 'pre_http_request', $stub ); + delete_transient( 'srfm_geo_' . md5( '203.0.113.77' ) ); + $this->clear_geo_server_vars(); + } + + /** + * Test get_geo_country returns the fallback when ipapi.co answers HTTP 200 + * with an error body (free-tier rate limiting), and caches the failure so + * the next call does not retry immediately. + */ + public function test_get_geo_country_falls_back_on_api_error_body() { + $this->clear_geo_server_vars(); + $_SERVER['REMOTE_ADDR'] = '203.0.113.88'; + delete_transient( 'srfm_geo_' . md5( '203.0.113.88' ) ); + + $requests = 0; + $stub = static function () use ( &$requests ) { + $requests++; + return [ + 'headers' => [], + 'body' => (string) wp_json_encode( + [ + 'error' => true, + 'reason' => 'RateLimited', + ] + ), + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + ]; + }; + add_filter( 'pre_http_request', $stub ); + + $this->assertSame( 'us', Helper::get_geo_country() ); + $this->assertSame( 'us', Helper::get_geo_country(), 'Failure must be cached for the failure TTL.' ); + $this->assertSame( 1, $requests, 'A cached failure must not retry the API.' ); + + remove_filter( 'pre_http_request', $stub ); + delete_transient( 'srfm_geo_' . md5( '203.0.113.88' ) ); + $this->clear_geo_server_vars(); + } + // --------------------------------------------------------------- // SRFM-2709: get_sureforms_website_url() UTM behavior // --------------------------------------------------------------- @@ -2717,6 +2844,20 @@ public function test_get_sureforms_website_url_handles_non_array_utm_args() { $this->assertStringNotContainsString( 'utm_', $url ); } + /** + * Clear CDN geo headers and IP-related $_SERVER variables for a clean test state. + */ + private function clear_geo_server_vars() { + $this->clear_ip_server_vars(); + unset( + $_SERVER['HTTP_CF_IPCOUNTRY'], + $_SERVER['HTTP_CLOUDFRONT_VIEWER_COUNTRY'], + $_SERVER['GEOIP_COUNTRY_CODE'], + $_SERVER['HTTP_X_GEO_COUNTRY'], + $_SERVER['HTTP_X_COUNTRY_CODE'] + ); + } + /** * Clear all IP-related $_SERVER variables for a clean test state. */ diff --git a/tests/unit/inc/test-rest-api.php b/tests/unit/inc/test-rest-api.php index 53d301290..2adf010d0 100644 --- a/tests/unit/inc/test-rest-api.php +++ b/tests/unit/inc/test-rest-api.php @@ -45,6 +45,62 @@ public function test_sanitize_boolean_field_falsy_values() { $this->assertFalse( $this->rest_api->sanitize_boolean_field( null ) ); } + // --------------------------------------------------------------- + // get_geo_country() + // --------------------------------------------------------------- + + /** + * With a CDN country header present the endpoint reports the detected + * country; with no header and no usable visitor IP it reports an empty + * country with detected=false so the frontend can apply its own fallback. + */ + public function test_get_geo_country() { + $geo_server_vars = [ + 'HTTP_CF_IPCOUNTRY', + 'HTTP_CLOUDFRONT_VIEWER_COUNTRY', + 'GEOIP_COUNTRY_CODE', + 'HTTP_X_GEO_COUNTRY', + 'HTTP_X_COUNTRY_CODE', + 'HTTP_CLIENT_IP', + 'HTTP_X_FORWARDED_FOR', + 'HTTP_X_REAL_IP', + 'HTTP_X_FORWARDED', + 'HTTP_FORWARDED_FOR', + 'HTTP_FORWARDED', + 'REMOTE_ADDR', + ]; + foreach ( $geo_server_vars as $server_var ) { + unset( $_SERVER[ $server_var ] ); + } + + // CDN header present → detected country. + $_SERVER['HTTP_CF_IPCOUNTRY'] = 'DE'; + + $response = $this->rest_api->get_geo_country(); + $this->assertInstanceOf( \WP_REST_Response::class, $response ); + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( + [ + 'country' => 'de', + 'detected' => true, + ], + $response->get_data() + ); + + // No CDN header and no usable visitor IP → not detected, empty country. + unset( $_SERVER['HTTP_CF_IPCOUNTRY'] ); + + $response = $this->rest_api->get_geo_country(); + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( + [ + 'country' => '', + 'detected' => false, + ], + $response->get_data() + ); + } + // --------------------------------------------------------------- // sanitize_entry_ids() // --------------------------------------------------------------- From 7e378f03a758f438a384df8cf0f87f77adcad277 Mon Sep 17 00:00:00 2001 From: Vansh Kapoor Date: Fri, 12 Jun 2026 10:16:16 +0530 Subject: [PATCH 05/25] fix: update Cloudflare Turnstile 'Get Keys' link to dashboard URL The 'Get Keys' button pointed to https://www.cloudflare.com/en-gb/products/turnstile/, a marketing page whose locale path now returns a 404 and never led to key creation. Point it to the Cloudflare dashboard Turnstile page where users can create a widget and retrieve their site and secret keys. --- src/admin/settings/components/security/Turnstile.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/admin/settings/components/security/Turnstile.js b/src/admin/settings/components/security/Turnstile.js index dec22ca38..194e0bc4c 100644 --- a/src/admin/settings/components/security/Turnstile.js +++ b/src/admin/settings/components/security/Turnstile.js @@ -49,7 +49,7 @@ const Turnstile = ( { securitytabOptions, updateGlobalSettings } ) => {