PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.1.1
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.1.1
1.6.0 1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
suredonation / inc / fields / phone-markup.php

phone-markup.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management 1.1.1, at inc/fields/phone-markup.php

269 lines 9.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * SureDonation Phone Number Markup Class.
4 *
5 * @package SureDonation
6 * @since 1.1.1
7 */
8
9 namespace SureDonation\Inc\Fields;
10
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit; // Exit if accessed directly.
13 }
14
15 use SureDonation\Inc\Helper;
16
17 /**
18 * Phone Number Markup Class.
19 *
20 * Renders a visible `<input type="tel">` (UI only, no name) enhanced on the front
21 * end by intl-tel-input, paired with a hidden `.sd-input-common` input that carries
22 * the submitted value (kept in sync by the frontend script via iti.getNumber()).
23 * The hidden input is what the shared scalar validator reads, mirroring the
24 * dropdown's hidden-input approach. See assets/build/blocks/phone.
25 *
26 * @since 1.1.1
27 */
28 class Phone_Markup extends Base {
29 /**
30 * Whether the visitor's country should be auto-detected.
31 *
32 * @var bool
33 * @since 1.1.1
34 */
35 protected $auto_country = true;
36
37 /**
38 * The default (initial) country code, lowercase 2-letter ISO.
39 *
40 * @var string
41 * @since 1.1.1
42 */
43 protected $default_country = '';
44
45 /**
46 * Whether the country filter is enabled.
47 *
48 * @var bool
49 * @since 1.1.1
50 */
51 protected $enable_country_filter = false;
52
53 /**
54 * Country filter type ('include' or 'exclude').
55 *
56 * @var string
57 * @since 1.1.1
58 */
59 protected $country_filter_type = 'include';
60
61 /**
62 * Country codes to include.
63 *
64 * @var array<int, string>
65 * @since 1.1.1
66 */
67 protected $include_countries = [];
68
69 /**
70 * Country codes to exclude.
71 *
72 * @var array<int, string>
73 * @since 1.1.1
74 */
75 protected $exclude_countries = [];
76
77 /**
78 * Initialize the properties based on block attributes.
79 *
80 * @param array<string, mixed> $attributes Block attributes.
81 * @since 1.1.1
82 */
83 public function __construct( $attributes ) {
84 $this->slug = 'phone';
85 $this->auto_country = ! isset( $attributes['autoCountry'] ) || ! empty( $attributes['autoCountry'] );
86 $this->default_country = isset( $attributes['defaultCountry'] ) ? strtolower( sanitize_text_field( Helper::get_string_value( $attributes['defaultCountry'] ) ) ) : '';
87 $this->enable_country_filter = ! empty( $attributes['enableCountryFilter'] );
88 $this->country_filter_type = isset( $attributes['countryFilterType'] ) && 'exclude' === $attributes['countryFilterType'] ? 'exclude' : 'include';
89 $this->include_countries = isset( $attributes['includeCountries'] ) && is_array( $attributes['includeCountries'] )
90 ? array_map( 'strtolower', array_map( 'sanitize_text_field', $attributes['includeCountries'] ) )
91 : [];
92 $this->exclude_countries = isset( $attributes['excludeCountries'] ) && is_array( $attributes['excludeCountries'] )
93 ? array_map( 'strtolower', array_map( 'sanitize_text_field', $attributes['excludeCountries'] ) )
94 : [];
95
96 // When auto country is enabled and no explicit default is set, detect the
97 // visitor's country via server-side IP geolocation (ipapi.co). See
98 // get_geo_country() for the why and the caching/rate-limit details.
99 if ( $this->auto_country && '' === $this->default_country ) {
100 $this->default_country = $this->get_geo_country();
101 }
102
103 $this->set_properties( $attributes );
104 $this->set_unique_slug();
105 $this->set_markup_properties();
106 }
107
108 /**
109 * Render phone number markup.
110 *
111 * @since 1.1.1
112 * @return string
113 */
114 public function markup() {
115 $classes = $this->get_field_classes( [ 'sd-phone-wrap-block' ] );
116 $aria_desc = $this->get_aria_describedby();
117 $data_slug = $this->block_slug ? $this->block_slug : $this->unique_slug;
118
119 ob_start();
120 ?>
121 <div data-block-id="<?php echo esc_attr( $this->block_id ); ?>" class="<?php echo esc_attr( $classes ); ?>">
122 <?php echo wp_kses_post( $this->label_markup ); ?>
123 <?php echo wp_kses_post( $this->help_markup ); ?>
124 <div class="sd-block-wrap">
125 <input
126 type="tel"
127 class="sd-input-phone"
128 id="<?php echo esc_attr( $this->unique_slug ); ?>"
129 <?php if ( ! empty( $aria_desc ) ) { ?>
130 aria-describedby="<?php echo esc_attr( $aria_desc ); ?>"
131 <?php } ?>
132 data-required="<?php echo esc_attr( $this->data_require_attr ); ?>"
133 aria-required="<?php echo esc_attr( $this->data_require_attr ); ?>"
134 data-default-country="<?php echo esc_attr( $this->default_country ); ?>"
135 data-auto-country="<?php echo $this->auto_country ? 'true' : 'false'; ?>"
136 data-enable-country-filter="<?php echo $this->enable_country_filter ? 'true' : 'false'; ?>"
137 data-country-filter-type="<?php echo esc_attr( $this->country_filter_type ); ?>"
138 <?php if ( $this->enable_country_filter && 'include' === $this->country_filter_type && ! empty( $this->include_countries ) ) { ?>
139 data-include-countries='<?php echo esc_attr( Helper::get_string_value( wp_json_encode( array_values( $this->include_countries ) ) ) ); ?>'
140 <?php } ?>
141 <?php if ( $this->enable_country_filter && 'exclude' === $this->country_filter_type && ! empty( $this->exclude_countries ) ) { ?>
142 data-exclude-countries='<?php echo esc_attr( Helper::get_string_value( wp_json_encode( array_values( $this->exclude_countries ) ) ) ); ?>'
143 <?php } ?>
144 <?php echo wp_kses_post( $this->placeholder_attr ); ?>
145 autocomplete="tel"
146 inputmode="tel"
147 />
148 <input
149 type="hidden"
150 class="sd-input-common sd-phone-hidden"
151 name="<?php echo esc_attr( $this->field_name ); ?>"
152 data-slug="<?php echo esc_attr( $data_slug ); ?>"
153 data-required="<?php echo esc_attr( $this->data_require_attr ); ?>"
154 />
155 </div>
156 <div class="sd-error-wrap"><?php echo wp_kses_post( $this->error_msg_markup ); ?></div>
157 </div>
158 <?php
159 return (string) ob_get_clean();
160 }
161
162 /**
163 * Detect the visitor's 2-letter country code via server-side IP geolocation.
164 *
165 * Calls ipapi.co once per visitor IP and caches the result in a transient for
166 * 24 hours so subsequent page loads resolve instantly without any API call.
167 *
168 * Failure responses (network error, non-200, malformed body, invalid country
169 * code) are cached as 'us' for 1 hour to prevent a retry storm if ipapi.co goes
170 * down or rate-limits us. Private/reserved IPs are rejected up front because
171 * ipapi.co cannot geolocate them. A site-wide hourly cap (default 40, filterable
172 * via `suredonation_phone_geo_api_hourly_cap`) bounds outbound calls so the
173 * ipapi free-tier quota cannot be exhausted by rotating spoofed IPs.
174 *
175 * @since 1.1.1
176 * @return string Lowercase 2-letter country code, defaults to 'us'.
177 */
178 private function get_geo_country() {
179 /**
180 * Global kill-switch for the third-party IP geolocation lookup.
181 *
182 * Complements the per-field "Auto-detect country" toggle: returning
183 * false here disables the ipapi.co transfer for every phone field at
184 * once (e.g. for privacy/GDPR compliance) without editing each form.
185 *
186 * @since 1.1.1
187 * @param bool $enabled Whether automatic IP-based country detection may run.
188 */
189 if ( ! apply_filters( 'suredonation_phone_geo_enabled', true ) ) {
190 return 'us';
191 }
192
193 // Never geolocate in the editor/REST context. The block is server-rendered,
194 // so ServerSideRender would otherwise geolocate the editor's IP — polluting
195 // the per-IP cache and burning the hourly quota on every form edit. The
196 // frontend script re-detects for real visitors, so the preview loses nothing.
197 if ( is_admin() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
198 return 'us';
199 }
200
201 $ip = Helper::get_client_ip();
202 if ( empty( $ip ) ) {
203 return 'us';
204 }
205
206 // Reject private/reserved IPs: ipapi.co cannot geolocate them.
207 if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
208 return 'us';
209 }
210
211 // Key the cache by a site-salted HMAC of the IP, not a bare md5 — md5 of an
212 // IPv4 address is trivially reversible via rainbow tables, and the key is
213 // stored in plaintext in the options table.
214 $cache_key = 'suredonation_phone_country_' . hash_hmac( 'sha256', $ip, wp_salt() );
215 $cached = get_transient( $cache_key );
216 if ( is_string( $cached ) && '' !== $cached ) {
217 return $cached;
218 }
219
220 // Site-wide hourly cap on outbound ipapi calls. The counter rolls over every
221 // hour (key includes YmdH) so it never needs an explicit reset. Default 40
222 // stays well under ipapi's 1,000/day free tier; raise via the filter.
223 $quota_key = 'suredonation_phone_geo_quota_' . gmdate( 'YmdH' );
224 $quota_cap = Helper::get_integer_value( apply_filters( 'suredonation_phone_geo_api_hourly_cap', 40 ) );
225 $count = Helper::get_integer_value( get_transient( $quota_key ) );
226 if ( $count >= $quota_cap ) {
227 set_transient( $cache_key, 'us', HOUR_IN_SECONDS );
228 return 'us';
229 }
230 set_transient( $quota_key, $count + 1, HOUR_IN_SECONDS );
231
232 // ipapi.co geolocates the *caller's* IP. Since this request originates from
233 // the WordPress server (not the visitor's browser), pass the visitor's IP
234 // explicitly via /{ip}/json/ — otherwise it returns the datacenter's country.
235 $url = 'https://ipapi.co/' . rawurlencode( $ip ) . '/json/';
236 $response = wp_remote_get(
237 $url,
238 [
239 'timeout' => 3,
240 'user-agent' => 'SureDonation/' . SUREDONATION_VER . ' (+https://suredonations.com)',
241 ]
242 );
243
244 if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
245 set_transient( $cache_key, 'us', HOUR_IN_SECONDS );
246 return 'us';
247 }
248
249 $body = json_decode( wp_remote_retrieve_body( $response ), true );
250
251 if ( ! is_array( $body ) || empty( $body['country_code'] ) || ! is_string( $body['country_code'] ) ) {
252 set_transient( $cache_key, 'us', HOUR_IN_SECONDS );
253 return 'us';
254 }
255
256 $country = strtolower( $body['country_code'] );
257
258 // Validate the external API response is a valid 2-letter country code.
259 if ( ! preg_match( '/^[a-z]{2}$/', $country ) ) {
260 set_transient( $cache_key, 'us', HOUR_IN_SECONDS );
261 return 'us';
262 }
263
264 set_transient( $cache_key, $country, DAY_IN_SECONDS );
265
266 return $country;
267 }
268 }
269