PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.10.0
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.10.0
2.12.8 2.12.7 2.12.6 2.12.5 2.12.4 2.12.3 2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 All 98 releases
sureforms / inc / fields / phone-markup.php

phone-markup.php in SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz 2.10.0, at inc/fields/phone-markup.php

251 lines 9.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Sureforms Phone Markup Class file.
4 *
5 * @package sureforms.
6 * @since 0.0.1
7 */
8
9 namespace SRFM\Inc\Fields;
10
11 use SRFM\Inc\Helper;
12
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit; // Exit if accessed directly.
15 }
16
17 /**
18 * Sureforms_Phone_Markup Class.
19 *
20 * @since 0.0.1
21 */
22 class Phone_Markup extends Base {
23 /**
24 * Stores the boolean string indicating if the country should be automatically determined.
25 *
26 * @var string
27 * @since 0.0.2
28 */
29 protected $auto_country;
30
31 /**
32 * Stores the default country code when auto country is disabled.
33 *
34 * @var string
35 * @since 1.12.1
36 */
37 protected $default_country;
38
39 /**
40 * Enable country filter toggle.
41 *
42 * @var bool
43 * @since 2.3.0
44 */
45 protected $enable_country_filter;
46
47 /**
48 * Country filter type (include or exclude).
49 *
50 * @var string
51 * @since 2.3.0
52 */
53 protected $country_filter_type;
54
55 /**
56 * Array of country codes to include.
57 *
58 * @var array
59 * @since 2.3.0
60 */
61 protected $include_countries;
62
63 /**
64 * Array of country codes to exclude.
65 *
66 * @var array
67 * @since 2.3.0
68 */
69 protected $exclude_countries;
70
71 /**
72 * Initialize the properties based on block attributes.
73 *
74 * @param array<mixed> $attributes Block attributes.
75 * @since 0.0.2
76 */
77 public function __construct( $attributes ) {
78 $this->set_properties( $attributes );
79 $this->set_input_label( __( 'Phone', 'sureforms' ) );
80 $this->set_error_msg( $attributes, 'srfm_phone_block_required_text' );
81 $this->set_duplicate_msg( $attributes, 'srfm_phone_block_unique_text' );
82 $this->slug = 'phone';
83 $this->auto_country = $attributes['autoCountry'] ?? '';
84 $this->default_country = $attributes['defaultCountry'] ?? '';
85 $this->enable_country_filter = $attributes['enableCountryFilter'] ?? false;
86 $this->country_filter_type = $attributes['countryFilterType'] ?? 'include';
87 $this->include_countries = $attributes['includeCountries'] ?? [];
88 $this->exclude_countries = $attributes['excludeCountries'] ?? [];
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.
92 //
93 // Why not get_locale()?
94 // WordPress get_locale() returns the *site's* configured language (e.g. 'en_US'),
95 // not the visitor's physical location. A site set to English would show 'US' for
96 // every visitor worldwide — defeating the purpose of auto-country detection.
97 //
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.
105 if ( $this->auto_country ) {
106 $this->default_country = $this->get_geo_country();
107 }
108 $this->set_unique_slug();
109 $this->set_field_name( $this->unique_slug );
110 $this->set_markup_properties( $this->input_label, true );
111 $this->set_aria_described_by();
112 $this->set_label_as_placeholder( $this->input_label );
113 }
114
115 /**
116 * Render the sureforms phone classic styling
117 *
118 * @since 0.0.2
119 * @return string|bool
120 */
121 public function markup() {
122 ob_start(); ?>
123 <div data-block-id="<?php echo esc_attr( $this->block_id ); ?>" class="srfm-block-single srfm-block srfm-<?php echo esc_attr( $this->slug ); ?>-block srf-<?php echo esc_attr( $this->slug ); ?>-<?php echo esc_attr( $this->block_id ); ?>-block<?php echo esc_attr( $this->block_width ); ?><?php echo esc_attr( $this->class_name ); ?> <?php echo esc_attr( $this->conditional_class ); ?>">
124 <?php echo wp_kses_post( $this->label_markup ); ?>
125 <?php echo wp_kses_post( $this->help_markup ); ?>
126 <div class="srfm-block-wrap">
127 <input type="tel"
128 class="srfm-input-common srfm-input-<?php echo esc_attr( $this->slug ); ?>"
129 name="<?php echo esc_attr( $this->field_name ); ?>"
130 id="<?php echo esc_attr( $this->unique_slug ); ?>"
131 <?php echo ! empty( $this->aria_described_by ) ? "aria-describedby='" . esc_attr( trim( $this->aria_described_by ) ) . "'" : ''; ?>
132 data-required="<?php echo esc_attr( $this->data_require_attr ); ?>"
133 aria-required="<?php echo esc_attr( $this->data_require_attr ); ?>"
134 default-country="<?php echo esc_attr( $this->default_country ); ?>"
135 <?php if ( $this->enable_country_filter ) { ?>
136 data-enable-country-filter="true"
137 data-country-filter-type="<?php echo esc_attr( $this->country_filter_type ); ?>"
138 <?php if ( 'include' === $this->country_filter_type && ! empty( $this->include_countries ) ) { ?>
139 data-include-countries="<?php echo esc_attr( Helper::get_string_value( wp_json_encode( $this->include_countries ) ) ); ?>"
140 <?php } elseif ( 'exclude' === $this->country_filter_type && ! empty( $this->exclude_countries ) ) { ?>
141 data-exclude-countries="<?php echo esc_attr( Helper::get_string_value( wp_json_encode( $this->exclude_countries ) ) ); ?>"
142 <?php } ?>
143 <?php } ?>
144 value="<?php echo esc_attr( $this->default ); ?>"
145 <?php echo wp_kses_post( $this->placeholder_attr ); ?>
146 data-unique="<?php echo esc_attr( $this->aria_unique ); ?>">
147 </div>
148 <div class="srfm-error-wrap">
149 <?php echo wp_kses_post( $this->duplicate_msg_markup ); ?>
150 </div>
151 </div>
152 <?php
153 return ob_get_clean();
154 }
155
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 }
251