| @@ -86,10 +86,15 @@ | ||
| 86 | 86 | $this->country_filter_type = $attributes['countryFilterType'] ?? 'include'; |
| 87 | 87 | $this->include_countries = $attributes['includeCountries'] ?? []; |
| 88 | 88 | $this->exclude_countries = $attributes['excludeCountries'] ?? []; |
| 89 | 89 | |
| 90 | - // When auto country is enabled, detect the visitor's country via server-side | |
| 91 | - // IP geolocation (ipapi.co) instead of a client-side fetch. | |
| 90 | + // When auto country is enabled, resolve a best-effort country here at render | |
| 91 | + // time to seed the baked `default-country` attribute (a sensible flag before | |
| 92 | + // any JS runs). The authoritative per-visitor detection happens client-side: | |
| 93 | + // phone.js applies an immediate network-free Intl guess and then refines it | |
| 94 | + // from the same-origin geo-country REST endpoint — so it stays correct even on | |
| 95 | + // full-page-cached sites, where this baked value would otherwise be the first | |
| 96 | + // visitor's country. | |
| 92 | 97 | // |
| 93 | 98 | // Why not get_locale()? |
| 94 | 99 | // WordPress get_locale() returns the *site's* configured language (e.g. 'en_US'), |
| 95 | 100 | // not the visitor's physical location. A site set to English would show 'US' for |
| @@ -94,17 +99,20 @@ | ||
| 94 | 99 | // WordPress get_locale() returns the *site's* configured language (e.g. 'en_US'), |
| 95 | 100 | // not the visitor's physical location. A site set to English would show 'US' for |
| 96 | 101 | // every visitor worldwide — defeating the purpose of auto-country detection. |
| 97 | 102 | // |
| 98 | - // Why server-side instead of client-side? | |
| 99 | - // The previous client-side fetch('https://ipapi.co/json') caused CORS failures, | |
| 100 | - // 429 rate limits on high-traffic sites, and exposed visitor IPs to a third party | |
| 101 | - // directly from the browser. Moving it server-side eliminates all three issues. | |
| 102 | - // | |
| 103 | - // Performance: The API is called only once per visitor IP and cached in a transient | |
| 104 | - // for 24 hours — subsequent page loads for the same IP resolve instantly from cache. | |
| 103 | + // Why resolve server-side at all (vs. a browser geo-IP fetch)? | |
| 104 | + // A client-side fetch('https://ipapi.co/json') caused CORS failures, 429 rate | |
| 105 | + // limits on high-traffic sites, and exposed visitor IPs to a third party from the | |
| 106 | + // browser. The server path (CDN header first, then a capped, cached ipapi.co | |
| 107 | + // lookup in Helper::get_geo_country()) avoids all three. | |
| 105 | 108 | if ( $this->auto_country ) { |
| 106 | - $this->default_country = $this->get_geo_country(); | |
| 109 | + // Pass the configured default as the fallback so a detection failure | |
| 110 | + // degrades to the user's chosen country instead of a hardcoded 'us'. | |
| 111 | + $fallback = ! empty( $this->default_country ) && is_string( $this->default_country ) | |
| 112 | + ? strtolower( $this->default_country ) | |
| 113 | + : 'us'; | |
| 114 | + $this->default_country = Helper::get_geo_country( $fallback ); | |
| 107 | 115 | } |
| 108 | 116 | $this->set_unique_slug(); |
| 109 | 117 | $this->set_field_name( $this->unique_slug ); |
| 110 | 118 | $this->set_markup_properties( $this->input_label, true ); |
| @@ -131,8 +139,11 @@ | ||
| 131 | 139 | <?php echo ! empty( $this->aria_described_by ) ? "aria-describedby='" . esc_attr( trim( $this->aria_described_by ) ) . "'" : ''; ?> |
| 132 | 140 | data-required="<?php echo esc_attr( $this->data_require_attr ); ?>" |
| 133 | 141 | aria-required="<?php echo esc_attr( $this->data_require_attr ); ?>" |
| 134 | 142 | default-country="<?php echo esc_attr( $this->default_country ); ?>" |
| 143 | + <?php if ( $this->auto_country ) { ?> | |
| 144 | + data-auto-country="true" | |
| 145 | + <?php } ?> | |
| 135 | 146 | <?php if ( $this->enable_country_filter ) { ?> |
| 136 | 147 | data-enable-country-filter="true" |
| 137 | 148 | data-country-filter-type="<?php echo esc_attr( $this->country_filter_type ); ?>" |
| 138 | 149 | <?php if ( 'include' === $this->country_filter_type && ! empty( $this->include_countries ) ) { ?> |
| @@ -152,99 +163,5 @@ | ||
| 152 | 163 | <?php |
| 153 | 164 | return ob_get_clean(); |
| 154 | 165 | } |
| 155 | 166 | |
| 156 | - /** | |
| 157 | - * Detect the visitor's 2-letter country code via server-side IP geolocation. | |
| 158 | - * | |
| 159 | - * Calls ipapi.co once per visitor IP and caches the result in a transient for | |
| 160 | - * 24 hours so subsequent page loads resolve instantly without any API call. | |
| 161 | - * | |
| 162 | - * Failure responses (network error, non-200, malformed body, invalid country code) | |
| 163 | - * are also cached as 'us' for 1 hour to prevent a thundering-herd retry storm | |
| 164 | - * if ipapi.co goes down or rate-limits us. | |
| 165 | - * | |
| 166 | - * Private/reserved IPs (e.g., 10.x, 192.168.x, 127.0.0.1, ::1) are rejected up | |
| 167 | - * front — ipapi.co cannot geolocate them, and accepting them would let spoofed | |
| 168 | - * X-Forwarded-For headers flood the transient cache. | |
| 169 | - * | |
| 170 | - * A site-wide hourly cap (default 40, filterable via `srfm_geo_api_hourly_cap`) | |
| 171 | - * bounds outbound calls so a determined attacker can't exhaust the ipapi free-tier | |
| 172 | - * quota (1,000/day) by rotating spoofed public IPs. | |
| 173 | - * | |
| 174 | - * Note on page caching: When a full-page cache plugin is active, the HTML | |
| 175 | - * (including default-country) is served from cache. The first visitor's country | |
| 176 | - * is baked into the cached page. This is an acceptable tradeoff — the alternative | |
| 177 | - * (client-side fetch) caused CORS failures and 429 rate limits. Sites needing | |
| 178 | - * per-visitor precision can set a specific default country per field. | |
| 179 | - * | |
| 180 | - * @since 2.8.0 | |
| 181 | - * @return string Lowercase 2-letter country code, defaults to 'us'. | |
| 182 | - */ | |
| 183 | - private function get_geo_country() { | |
| 184 | - $ip = Helper::get_visitor_ip(); | |
| 185 | - if ( empty( $ip ) ) { | |
| 186 | - return 'us'; | |
| 187 | - } | |
| 188 | - | |
| 189 | - // Reject private/reserved IPs: ipapi.co returns "Reserved IP Address" for them, | |
| 190 | - // and accepting them would let spoofed X-Forwarded-For headers flood the cache. | |
| 191 | - if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { | |
| 192 | - return 'us'; | |
| 193 | - } | |
| 194 | - | |
| 195 | - $cache_key = 'srfm_geo_' . md5( $ip ); | |
| 196 | - $cached = get_transient( $cache_key ); | |
| 197 | - if ( is_string( $cached ) && '' !== $cached ) { | |
| 198 | - return $cached; | |
| 199 | - } | |
| 200 | - | |
| 201 | - // Site-wide hourly cap on outbound ipapi calls. The counter rolls over every hour | |
| 202 | - // (key includes YmdH) so we never need to explicitly reset it. Default 40 stays | |
| 203 | - // well under ipapi's 1,000/day free tier; paid-tier sites can raise via filter. | |
| 204 | - $quota_key = 'srfm_geo_quota_' . gmdate( 'YmdH' ); | |
| 205 | - $quota_cap = Helper::get_integer_value( apply_filters( 'srfm_geo_api_hourly_cap', 40 ) ); | |
| 206 | - $count = Helper::get_integer_value( get_transient( $quota_key ) ); | |
| 207 | - if ( $count >= $quota_cap ) { | |
| 208 | - set_transient( $cache_key, 'us', HOUR_IN_SECONDS ); | |
| 209 | - return 'us'; | |
| 210 | - } | |
| 211 | - set_transient( $quota_key, $count + 1, HOUR_IN_SECONDS ); | |
| 212 | - | |
| 213 | - // ipapi.co's /json/ endpoint geolocates the *caller's* IP. Since this request | |
| 214 | - // originates from the WordPress server (not the visitor's browser), we must | |
| 215 | - // pass the visitor's IP explicitly via /{ip}/json/ — otherwise ipapi.co | |
| 216 | - // returns the hosting datacenter's country for every visitor. | |
| 217 | - $url = 'https://ipapi.co/' . rawurlencode( $ip ) . '/json/'; | |
| 218 | - $response = wp_remote_get( | |
| 219 | - $url, | |
| 220 | - [ | |
| 221 | - 'timeout' => 3, | |
| 222 | - 'user-agent' => 'SureForms/' . SRFM_VER . ' (+https://sureforms.com)', | |
| 223 | - ] | |
| 224 | - ); | |
| 225 | - | |
| 226 | - if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { | |
| 227 | - set_transient( $cache_key, 'us', HOUR_IN_SECONDS ); | |
| 228 | - return 'us'; | |
| 229 | - } | |
| 230 | - | |
| 231 | - $body = json_decode( wp_remote_retrieve_body( $response ), true ); | |
| 232 | - | |
| 233 | - if ( ! is_array( $body ) || empty( $body['country_code'] ) || ! is_string( $body['country_code'] ) ) { | |
| 234 | - set_transient( $cache_key, 'us', HOUR_IN_SECONDS ); | |
| 235 | - return 'us'; | |
| 236 | - } | |
| 237 | - | |
| 238 | - $country = strtolower( $body['country_code'] ); | |
| 239 | - | |
| 240 | - // Validate the external API response is a valid 2-letter country code. | |
| 241 | - if ( ! preg_match( '/^[a-z]{2}$/', $country ) ) { | |
| 242 | - set_transient( $cache_key, 'us', HOUR_IN_SECONDS ); | |
| 243 | - return 'us'; | |
| 244 | - } | |
| 245 | - | |
| 246 | - set_transient( $cache_key, $country, DAY_IN_SECONDS ); | |
| 247 | - | |
| 248 | - return $country; | |
| 249 | - } | |
| 250 | 167 | } |