PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.7
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.7
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
← All changes | inc/helper.php +571 -21 2.7.1 → 2.12.7 View file →
@@ -7,8 +7,9 @@
7 7 */
8 8
9 9 namespace SRFM\Inc;
10 10
11 +use SRFM\Inc\Compatibility\Multilingual\String_Translator;
11 12 use SRFM\Inc\Database\Tables\Entries;
12 13 use SRFM\Inc\Traits\Get_Instance;
13 14 use WP_Error;
14 15 use WP_Post;
@@ -65,11 +66,12 @@
65 66 * @since 0.0.2
66 67 * @return array<string>
67 68 */
68 69 public static function get_common_err_msg() {
70 + $translator = String_Translator::get_instance();
69 71 return [
70 - 'required' => __( 'This field is required.', 'sureforms' ),
71 - 'unique' => __( 'Value needs to be unique.', 'sureforms' ),
72 + 'required' => $translator->translate_validation_message( 'srfm_required_field', __( 'This field is required.', 'sureforms' ) ),
73 + 'unique' => $translator->translate_validation_message( 'srfm_unique_field', __( 'Value needs to be unique.', 'sureforms' ) ),
72 74 ];
73 75 }
74 76
75 77 /**
@@ -189,8 +191,16 @@
189 191
190 192 /**
191 193 * Extracts the field label from the dynamic field key ( or field slug ).
192 194 *
195 + * ALWAYS escape the return value at the sink. The label is decoded from the
196 + * submitted key via {@see self::decode()}, so it is submitter-controlled and
197 + * unauthenticated — and this method is strictly more dangerous than decode()
198 + * alone, because it additionally runs html_entity_decode(), which expands
199 + * entities and therefore undoes any htmlspecialchars()-on-store defence.
200 + * When a label's integrity matters, read it from the form's stored block
201 + * definitions by block id instead of from the submitted key.
202 + *
193 203 * @param string $field_key Dynamic field key.
194 204 * @since 1.1.1
195 205 * @return string Extracted field label.
196 206 */
@@ -199,12 +209,14 @@
199 209 return '';
200 210 }
201 211
202 212 $label = explode( '-lbl-', $field_key )[1];
203 - // Getting the encrypted label. we are removing the block slug here.
213 + // Getting the encoded label. we are removing the block slug here.
204 214 $label = explode( '-', $label )[0];
205 215
206 - return $label ? html_entity_decode( self::decrypt( $label ) ) : '';
216 + // The result is submitter-controlled and unauthenticated — escape it for its
217 + // context at the sink (esc_html, escape_csv_formula, ...), never trust it.
218 + return $label ? html_entity_decode( self::decode( $label ) ) : '';
207 219 }
208 220
209 221 /**
210 222 * Extracts the block ID from the dynamic field key ( or field slug ).
@@ -316,8 +328,45 @@
316 328 return $result;
317 329 }
318 330
319 331 /**
332 + * Sanitize a value based on its PHP type.
333 + *
334 + * Recursively sanitizes arrays while preserving native PHP types (bool, int, float).
335 + * Use this for complex object metas with many properties where per-field callbacks are impractical.
336 + *
337 + * @param mixed $value The value to sanitize.
338 + * @param int $depth Current recursion depth. Values nested beyond 10 levels are discarded.
339 + * @since 2.8.0
340 + * @return mixed The sanitized value.
341 + */
342 + public static function sanitize_by_type( $value, int $depth = 0 ) {
343 + if ( $depth > 10 ) {
344 + return '';
345 + }
346 + if ( is_array( $value ) ) {
347 + $sanitized = [];
348 + foreach ( $value as $key => $val ) {
349 + $sanitized[ sanitize_text_field( (string) $key ) ] = self::sanitize_by_type( $val, $depth + 1 );
350 + }
351 + return $sanitized;
352 + }
353 + if ( is_bool( $value ) ) {
354 + return $value;
355 + }
356 + if ( is_int( $value ) ) {
357 + return intval( $value );
358 + }
359 + if ( is_float( $value ) ) {
360 + return floatval( $value );
361 + }
362 + if ( is_string( $value ) ) {
363 + return sanitize_text_field( $value );
364 + }
365 + return '';
366 + }
367 +
368 + /**
320 369 * This function performs array_map for multi dimensional array
321 370 *
322 371 * @param string $function function name to be applied on each element on array.
323 372 * @param array<mixed> $data_array array on which function needs to be performed.
@@ -374,9 +423,9 @@
374 423 if ( $label ) {
375 424 ob_start();
376 425 ?>
377 426 <label id="srfm-label-<?php echo esc_attr( $block_id ); ?>" for="srfm-<?php echo esc_attr( $slug ); ?>-<?php echo esc_attr( $block_id ); ?>" class="srfm-block-label">
378 - <?php echo wp_kses_post( $label ); ?>
427 + <?php echo esc_html( $label ); ?>
379 428 <?php if ( $required ) { ?>
380 429 <span class="srfm-required" aria-hidden="true"> *</span>
381 430 <?php } ?>
382 431 </label>
@@ -388,9 +437,9 @@
388 437 if ( $help ) {
389 438 ob_start();
390 439 ?>
391 440 <div class="srfm-description" id="srfm-description-<?php echo esc_attr( $block_id ); ?>">
392 - <?php echo wp_kses_post( $help ); ?>
441 + <?php echo esc_html( $help ); ?>
393 442 </div>
394 443 <?php
395 444 $markup = ob_get_clean();
396 445 }
@@ -417,9 +466,9 @@
417 466 $markup = ob_get_clean();
418 467 }
419 468 break;
420 469 case 'placeholder':
421 - $markup = $label && '1' === $show_labels_as_placeholder ? wp_kses_post( $label ) . ( $required ? esc_attr( $required_sign ) : '' ) : '';
470 + $markup = $label && '1' === $show_labels_as_placeholder ? esc_html( $label ) . ( $required ? esc_attr( $required_sign ) : '' ) : '';
422 471 break;
423 472 case 'label_text':
424 473 // This has been added for generating label text for the form markup instead of adding it in the label tag.
425 474 if ( $label ) {
@@ -424,9 +473,9 @@
424 473 // This has been added for generating label text for the form markup instead of adding it in the label tag.
425 474 if ( $label ) {
426 475 ob_start();
427 476 ?>
428 - <?php echo wp_kses_post( $label ); ?>
477 + <?php echo esc_html( $label ); ?>
429 478 <?php if ( $required ) { ?>
430 479 <span class="srfm-required" aria-hidden="true"> *</span>
431 480 <?php } ?>
432 481 <?php
@@ -470,15 +519,34 @@
470 519 return is_string( $output ) ? $output : '';
471 520 }
472 521
473 522 /**
474 - * Encrypt data using base64.
523 + * Base64-encode a string for use inside a field key.
475 524 *
476 - * @param string $input The input string which needs to be encrypted.
477 - * @since 0.0.1
478 - * @return string The encrypted string.
525 + * Replaces encrypt(), which is retained as a deprecated alias. The encoding itself
526 + * is unchanged since 0.0.1 — only the name is new.
527 + *
528 + * NOT ENCRYPTION. This is plain, unkeyed base64 (padding stripped) used only to
529 + * carry a label inside a field key. There is no key, no HMAC and no integrity
530 + * protection, so a value round-tripped through decode() is fully attacker-forgeable
531 + * and must never be treated as authentic or trusted as a security boundary. When a
532 + * label's integrity matters, look it up from the form's stored block definitions by
533 + * block id instead of decoding it from the submitted key.
534 + *
535 + * Two behaviours worth knowing before relying on this pair:
536 + *
537 + * - The input is run through wp_strip_all_tags(), so this is not a lossless
538 + * round trip: decode( encode( $x ) ) !== $x whenever $x contains markup.
539 + * That stripping happens on this trusted side only — decode() returns raw
540 + * submitted bytes and does NOT strip anything, so every sink must escape for
541 + * its own context (esc_html(), escape_csv_formula(), ...).
542 + * - Falsy input (including the string '0') returns '', not base64.
543 + *
544 + * @param string $input The input string to encode.
545 + * @since 2.12.3
546 + * @return string The base64-encoded string (padding removed).
479 547 */
480 - public static function encrypt( $input ) {
548 + public static function encode( $input ) {
481 549 // If the input is empty or not a string, then abandon ship.
482 550 if ( empty( $input ) || ! is_string( $input ) ) {
483 551 return '';
484 552 }
@@ -485,32 +553,67 @@
485 553
486 554 // Strip HTML tags to prevent them from being included in IDs and field names.
487 555 $input = wp_strip_all_tags( $input );
488 556
489 - // Encrypt the input and return it.
557 + // Base64-encode the input and return it.
490 558 $base_64 = base64_encode( $input ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
491 559 return rtrim( $base_64, '=' );
492 560 }
493 561
494 562 /**
495 - * Decrypt data using base64.
563 + * Base64-encode a string.
496 564 *
497 - * @param string $input The input string which needs to be decrypted.
565 + * @deprecated 2.12.3 Use {@see self::encode()}. The name wrongly implied a security
566 + * boundary — this is unkeyed base64, not encryption.
567 + *
568 + * @param string $input The input string to encode.
498 569 * @since 0.0.1
499 - * @return string The decrypted string.
570 + * @return string The base64-encoded string.
500 571 */
501 - public static function decrypt( $input ) {
572 + public static function encrypt( $input ) {
573 + return self::encode( $input );
574 + }
575 +
576 + /**
577 + * Base64-decode a string produced by encode().
578 + *
579 + * Replaces decrypt(), which is retained as a deprecated alias. The decoding itself
580 + * is unchanged since 0.0.1 — only the name is new.
581 + *
582 + * NOT DECRYPTION. See {@see self::encode()} — the result is unauthenticated and
583 + * attacker-forgeable; do not trust it where integrity matters. The output is raw
584 + * submitted bytes: no tag stripping, no sanitising. Escape it at the sink.
585 + *
586 + * @param string $input The input string to decode.
587 + * @since 2.12.3
588 + * @return string The decoded string.
589 + */
590 + public static function decode( $input ) {
502 591 // If the input is empty or not a string, then abandon ship.
503 592 if ( empty( $input ) || ! is_string( $input ) ) {
504 593 return '';
505 594 }
506 595
507 - // Decrypt the input and return it.
596 + // Base64-decode the input and return it.
508 597 $base_64 = $input . str_repeat( '=', strlen( $input ) % 4 );
509 598 return base64_decode( $base_64 ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
510 599 }
511 600
512 601 /**
602 + * Base64-decode a string.
603 + *
604 + * @deprecated 2.12.3 Use {@see self::decode()}. The name wrongly implied a security
605 + * boundary — this is unkeyed base64, not decryption.
606 + *
607 + * @param string $input The input string to decode.
608 + * @since 0.0.1
609 + * @return string The decoded string.
610 + */
611 + public static function decrypt( $input ) {
612 + return self::decode( $input );
613 + }
614 +
615 + /**
513 616 * Update an option from the database.
514 617 *
515 618 * @param string $key The option key.
516 619 * @param mixed $value The value to update.
@@ -646,8 +749,43 @@
646 749 );
647 750 }
648 751
649 752 /**
753 + * Resolve the submitting user, surviving REST's nonce-less de-authentication.
754 + *
755 + * The public form endpoints authenticate with the HMAC Submit_Token rather than
756 + * a nonce, because the form markup is page-cacheable and core answers a nonce
757 + * that fails verification with a hard 403 — a value baked into a cached page
758 + * would break submissions once it aged out.
759 + *
760 + * The trade-off is that `rest_cookie_check_errors()` treats a cookie-carrying
761 + * REST request with no nonce as anonymous and calls `wp_set_current_user( 0 )`
762 + * before dispatch. So `get_current_user_id()` returns 0 during a submission even
763 + * when the visitor is signed in, which silently drops entry attribution and
764 + * blanks every `{user_*}` smart tag.
765 + *
766 + * `wp_validate_auth_cookie()` reads the logged-in cookie directly and is
767 + * unaffected by that reset. It verifies the cookie's HMAC, so the identity is
768 + * authenticated, not merely asserted — this is the same check core itself uses
769 + * for cookie auth, and the pattern already used by the Pro login route.
770 + *
771 + * Returns 0 for genuinely anonymous submissions, so callers can keep treating
772 + * falsy as "not logged in".
773 + *
774 + * @since 2.12.6
775 + * @return int User ID, or 0 when the submitter is not signed in.
776 + */
777 + public static function get_submitting_user_id() {
778 + $user_id = get_current_user_id();
779 +
780 + if ( $user_id ) {
781 + return $user_id;
782 + }
783 +
784 + return absint( wp_validate_auth_cookie( '', 'logged_in' ) );
785 + }
786 +
787 + /**
650 788 * Check if the current user has a given capability.
651 789 *
652 790 * @param string $capability The capability to check.
653 791 * @param array<mixed> $args Optional. Additional arguments to pass to the capability check.
@@ -1430,12 +1568,43 @@
1430 1568 if ( ! is_array( $utm_args ) ) {
1431 1569 $utm_args = [];
1432 1570 }
1433 1571
1572 + // SRFM-2709: deterministic UTM attribution — start.
1573 + // When the caller opts into UTM tracking by passing any utm_args, fill in
1574 + // SureForms' deterministic source/campaign defaults. Caller-provided keys
1575 + // (including the placement passed via utm_medium) always win.
1576 + if ( ! empty( $utm_args ) ) {
1577 + $utm_args = array_merge(
1578 + [
1579 + 'utm_source' => 'sureforms_plugin',
1580 + 'utm_campaign' => 'core_plugin',
1581 + ],
1582 + $utm_args
1583 + );
1584 + }
1585 + // SRFM-2709: deterministic UTM attribution — end.
1586 +
1434 1587 if ( class_exists( 'BSF_UTM_Analytics' ) ) {
1435 1588 $url = \BSF_UTM_Analytics::get_utm_ready_link( $url, 'sureforms', $utm_args );
1436 1589 }
1437 1590
1591 + // SRFM-2709: post-BSF_UTM_Analytics fallback — start.
1592 + // BSF_UTM_Analytics returns the URL unchanged when no install referer is
1593 + // recorded. Merge any caller UTM keys still missing from the final URL.
1594 + if ( ! empty( $utm_args ) ) {
1595 + $existing = [];
1596 + $query = wp_parse_url( $url, PHP_URL_QUERY );
1597 + if ( is_string( $query ) && '' !== $query ) {
1598 + parse_str( $query, $existing );
1599 + }
1600 + $missing = array_diff_key( $utm_args, $existing );
1601 + if ( ! empty( $missing ) ) {
1602 + $url = add_query_arg( $missing, $url );
1603 + }
1604 + }
1605 + // SRFM-2709: post-BSF_UTM_Analytics fallback — end.
1606 +
1438 1607 return esc_url( $url );
1439 1608 }
1440 1609
1441 1610 /**
@@ -1727,9 +1896,9 @@
1727 1896 *
1728 1897 * @return array<mixed>
1729 1898 */
1730 1899 public static function sureforms_get_integration() {
1731 - $suretrigger_connected = apply_filters( 'suretriggers_is_user_connected', '' );
1900 + $suretrigger_connected = apply_filters( 'suretriggers_is_user_connected', '' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- SureTriggers' own filter; the name must match SureTriggers exactly to integrate.
1732 1901 $logo_sure_triggers = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/suretriggers.svg' );
1733 1902 $logo_full = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/suretriggers_full.svg' );
1734 1903 $logo_sure_mails = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/suremails.svg' );
1735 1904 $logo_uae = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/uae.svg' );
@@ -1735,10 +1904,20 @@
1735 1904 $logo_uae = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/uae.svg' );
1736 1905 $logo_starter_templates = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/starterTemplates.svg' );
1737 1906 $logo_sure_rank = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/surerank.svg' );
1738 1907 $logo_sure_contact = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/surecontact.svg' );
1908 + $logo_sure_donation = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/suredonation.svg' );
1739 1909
1740 1910 $integrations = [
1911 + 'sure_donation' => [
1912 + 'title' => __( 'SureDonation', 'sureforms' ),
1913 + 'singleLineDescription' => __( 'Start Collecting Donations Today', 'sureforms' ),
1914 + 'subtitle' => __( 'Want to accept donations too? SureDonation makes it easy to collect contributions right on your WordPress site.', 'sureforms' ),
1915 + 'status' => self::get_plugin_status( 'suredonation/suredonation.php' ),
1916 + 'slug' => 'suredonation',
1917 + 'path' => 'suredonation/suredonation.php',
1918 + 'logo' => self::encode_svg( is_string( $logo_sure_donation ) ? $logo_sure_donation : '' ),
1919 + ],
1741 1920 'sure_contact' => [
1742 1921 'title' => __( 'SureContact', 'sureforms' ),
1743 1922 'singleLineDescription' => __( 'Turn Emails Into Revenue with a CRM Built for Your Website!', 'sureforms' ),
1744 1923 'subtitle' => __( 'Send newsletters, run campaigns, set up automations, manage contacts, and see exactly how much revenue your emails generate, all in one place.', 'sureforms' ),
@@ -2030,8 +2209,57 @@
2030 2209 return implode( '-', array_slice( explode( '-', explode( '-lbl-', $field_name )[0] ), 0, 2 ) );
2031 2210 }
2032 2211
2033 2212 /**
2213 + * The active caching plugin, if there is one.
2214 + *
2215 + * Caching matters to SureForms because a cached page serves the same HTML to
2216 + * everyone: the submission token is embedded at render time, and an
2217 + * aggressively cached or JS-combining setup can serve a stale token or reorder
2218 + * the scripts a form depends on. This is what surfaces that to the site owner
2219 + * before it turns into "my form stopped working".
2220 + *
2221 + * @since 2.12.6
2222 + * @return string Human-readable plugin name, or '' when none is active.
2223 + */
2224 + public static function get_active_caching_plugin() {
2225 + $entry = self::get_active_caching_plugin_entry();
2226 +
2227 + return null === $entry ? '' : $entry[0];
2228 + }
2229 +
2230 + /**
2231 + * Setup guide for the active caching plugin.
2232 + *
2233 + * Six of the recognised plugins have a guide of their own; the rest, and any
2234 + * site with none detected, get the general one. Sending someone to a page that
2235 + * names the plugin they actually run is the difference between advice they can
2236 + * follow and advice they have to translate.
2237 + *
2238 + * Falls back to the general guide rather than returning nothing, so the notice
2239 + * always has somewhere to send them.
2240 + *
2241 + * @since 2.12.7
2242 + * @return string Absolute documentation URL.
2243 + */
2244 + public static function get_caching_plugin_doc_url() {
2245 + $entry = self::get_active_caching_plugin_entry();
2246 + $slug = null === $entry || '' === $entry[1] ? 'how-to-set-up-sureforms-with-caching-plugins' : $entry[1];
2247 +
2248 + // Through the central builder rather than hardcoding the domain, so the
2249 + // link carries the same UTM attribution as every other doc link and a
2250 + // domain change is one edit. utm_content is the slug, so the notice can be
2251 + // told which guide people actually open.
2252 + return self::get_sureforms_website_url(
2253 + 'docs/' . $slug . '/',
2254 + [
2255 + 'utm_medium' => 'form_checks_notice',
2256 + 'utm_content' => $slug,
2257 + ]
2258 + );
2259 + }
2260 +
2261 + /**
2034 2262 * Check if any of the top 10 popular WordPress SMTP plugins is active using array_intersect.
2035 2263 *
2036 2264 * @since 1.9.1
2037 2265 * @return bool True if any SMTP plugin is active, false otherwise.
@@ -2082,9 +2310,9 @@
2082 2310 return $default;
2083 2311 }
2084 2312
2085 2313 // Apply the filter with additional arguments.
2086 - $filtered = apply_filters( $filter_name, $default, ...$args );
2314 + $filtered = apply_filters( $filter_name, $default, ...$args ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Generic dynamic-filter dispatcher; the caller supplies the (already prefixed) hook name.
2087 2315
2088 2316 // Return filtered result if it's a non-empty array.
2089 2317 return is_array( $filtered ) && ! empty( $filtered ) ? $filtered : $default;
2090 2318 }
@@ -2309,7 +2537,329 @@
2309 2537 }
2310 2538
2311 2539 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
2312 2540 return base64_encode( $json );
2541 + }
2542 +
2543 + /**
2544 + * Get the visitor's IP address.
2545 + *
2546 + * Centralised IP detection that checks common proxy headers before
2547 + * falling back to REMOTE_ADDR. Handles comma-separated IPs that
2548 + * load-balancers / CDNs may append (takes the first, i.e. client IP).
2549 + *
2550 + * NOTE: Existing callers (Smart_Tags::get_the_user_ip, Front_End::get_user_ip,
2551 + * inline reads in Form_Submit) can be migrated to this method in the future
2552 + * to avoid duplicating the same header-chain logic.
2553 + *
2554 + * @since 2.8.0
2555 + * @return string Validated IP address, or empty string if unavailable.
2556 + */
2557 + public static function get_visitor_ip() {
2558 + $headers = [
2559 + 'HTTP_CLIENT_IP',
2560 + 'HTTP_X_FORWARDED_FOR',
2561 + 'HTTP_X_REAL_IP',
2562 + 'HTTP_X_FORWARDED',
2563 + 'HTTP_FORWARDED_FOR',
2564 + 'HTTP_FORWARDED',
2565 + 'REMOTE_ADDR',
2566 + ];
2567 +
2568 + foreach ( $headers as $header ) {
2569 + if ( empty( $_SERVER[ $header ] ) ) {
2570 + continue;
2571 + }
2572 +
2573 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Validated by FILTER_VALIDATE_IP below.
2574 + $raw = wp_unslash( $_SERVER[ $header ] );
2575 +
2576 + // Proxies may send comma-separated IPs; the first is the original client.
2577 + if ( false !== strpos( $raw, ',' ) ) {
2578 + $raw = trim( explode( ',', $raw )[0] );
2579 + }
2580 +
2581 + $ip = filter_var( $raw, FILTER_VALIDATE_IP );
2582 + if ( false !== $ip ) {
2583 + /**
2584 + * Filters the detected visitor IP address.
2585 + *
2586 + * @since 2.8.0
2587 + *
2588 + * @param string $ip Validated IP address.
2589 + */
2590 + return apply_filters( 'srfm_visitor_ip', $ip );
2591 + }
2592 + }
2593 +
2594 + return '';
2595 + }
2596 +
2597 + /**
2598 + * Detect the visitor's 2-letter country code via server-side IP geolocation.
2599 + *
2600 + * Prefers a CDN/server-provided country header (Cloudflare, CloudFront, mod_geoip)
2601 + * when present — free, instant and cache-safe. Otherwise calls ipapi.co once per
2602 + * visitor IP and caches the result in a transient for 24 hours so subsequent
2603 + * lookups for the same IP resolve instantly. Failures are cached for a short TTL
2604 + * (see get_geo_failure_ttl()) to avoid retry storms while still self-healing, and
2605 + * a site-wide hourly cap (filterable via `srfm_geo_api_hourly_cap`, default 40)
2606 + * bounds outbound calls. Private/reserved IPs are rejected up front.
2607 + *
2608 + * Intended to be called per-visitor (e.g. via the geo-country REST route) so
2609 + * the result is correct on full-page-cached sites instead of being baked into
2610 + * the cached HTML.
2611 + *
2612 + * Local testing: private/loopback IPs (e.g. 127.0.0.1) cannot be geolocated, so
2613 + * inject a public IP via the `srfm_visitor_ip` filter to exercise detection:
2614 + *
2615 + * add_filter( 'srfm_visitor_ip', static fn() => '8.8.8.8' ); // US; try 1.1.1.1 etc.
2616 + *
2617 + * @param string $fallback Country code returned when detection is unavailable.
2618 + * @since 2.11.1
2619 + * @return string Lowercase 2-letter country code.
2620 + */
2621 + public static function get_geo_country( $fallback = 'us' ) {
2622 + // Prefer a CDN/server-provided country header — free, instant, per-visitor
2623 + // and cache-safe. It is independent of the connecting IP (it still resolves
2624 + // when the visitor IP is private/loopback, e.g. local dev or behind a
2625 + // proxy), so it must be checked before the IP-based path below.
2626 + $cdn_country = self::get_cdn_country();
2627 + if ( '' !== $cdn_country ) {
2628 + return $cdn_country;
2629 + }
2630 +
2631 + $ip = self::get_visitor_ip();
2632 + if ( empty( $ip ) ) {
2633 + return $fallback;
2634 + }
2635 +
2636 + // Reject private/reserved IPs: ipapi.co cannot geolocate them, and accepting
2637 + // them would let spoofed X-Forwarded-For headers flood the transient cache.
2638 + if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
2639 + return $fallback;
2640 + }
2641 +
2642 + // Versioned key (v2) so stale failure transients written by older code
2643 + // (which used the byte-identical 'srfm_geo_' key and a 1h TTL) don't shadow
2644 + // the current CDN/fallback logic for the failure-TTL window after an upgrade.
2645 + $cache_key = 'srfm_geo_v2_' . md5( $ip );
2646 + $cached = get_transient( $cache_key );
2647 + if ( is_string( $cached ) && '' !== $cached ) {
2648 + return $cached;
2649 + }
2650 +
2651 + // Site-wide hourly cap on outbound ipapi calls; the counter rolls over
2652 + // every hour (key includes YmdH) so it never needs an explicit reset.
2653 + $quota_key = 'srfm_geo_quota_' . gmdate( 'YmdH' );
2654 + $quota_cap = self::get_integer_value( apply_filters( 'srfm_geo_api_hourly_cap', 40 ) );
2655 + $count = self::get_integer_value( get_transient( $quota_key ) );
2656 + if ( $count >= $quota_cap ) {
2657 + self::srfm_log( $quota_cap, 'SRFM geo lookup skipped (hourly cap reached):' );
2658 + // Do NOT cache a per-IP transient here: the hourly counter already
2659 + // blocks outbound calls, and writing per IP is the one path not bounded
2660 + // by the cap — it would let spoofed X-Forwarded-For headers churn
2661 + // wp_options / the object cache under sustained traffic.
2662 + return $fallback;
2663 + }
2664 + set_transient( $quota_key, $count + 1, HOUR_IN_SECONDS );
2665 +
2666 + // Pass the visitor's IP explicitly via /{ip}/json/ — the request originates
2667 + // from the server, so the bare /json/ endpoint would return the host's country.
2668 + $url = 'https://ipapi.co/' . rawurlencode( $ip ) . '/json/';
2669 +
2670 + // ipapi.co's free (keyless) tier is heavily rate-limited, so unauthenticated
2671 + // lookups are best-effort and often fail to the configured fallback. Sites
2672 + // that need reliable IP-based detection can supply a paid ipapi.co key via
2673 + // this filter; CDN-fronted sites resolve earlier via get_cdn_country() and
2674 + // never reach this call.
2675 + $api_key = self::get_string_value( apply_filters( 'srfm_ipapi_api_key', '' ) );
2676 + if ( '' !== $api_key ) {
2677 + $url = add_query_arg( 'key', rawurlencode( $api_key ), $url );
2678 + }
2679 +
2680 + $response = wp_remote_get(
2681 + $url,
2682 + [
2683 + 'timeout' => 3,
2684 + 'user-agent' => 'SureForms/' . SRFM_VER . ' (+https://sureforms.com)',
2685 + ]
2686 + );
2687 +
2688 + if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
2689 + $detail = is_wp_error( $response ) ? $response->get_error_message() : wp_remote_retrieve_response_code( $response );
2690 + self::srfm_log( $detail, 'SRFM geo lookup failed (transport):' );
2691 + set_transient( $cache_key, $fallback, self::get_geo_failure_ttl() );
2692 + return $fallback;
2693 + }
2694 +
2695 + $body = json_decode( wp_remote_retrieve_body( $response ), true );
2696 +
2697 + // ipapi.co's free tier returns HTTP 200 with a JSON error body
2698 + // (e.g. {"error":true,"reason":"RateLimited"}) when throttled — treat as a failure.
2699 + if ( is_array( $body ) && ! empty( $body['error'] ) ) {
2700 + $reason = ! empty( $body['reason'] ) && is_string( $body['reason'] ) ? $body['reason'] : 'unknown';
2701 + self::srfm_log( $reason, 'SRFM geo lookup failed (ipapi error):' );
2702 + set_transient( $cache_key, $fallback, self::get_geo_failure_ttl() );
2703 + return $fallback;
2704 + }
2705 +
2706 + if ( ! is_array( $body ) || empty( $body['country_code'] ) || ! is_string( $body['country_code'] ) ) {
2707 + self::srfm_log( wp_remote_retrieve_response_code( $response ), 'SRFM geo lookup failed (no country_code):' );
2708 + set_transient( $cache_key, $fallback, self::get_geo_failure_ttl() );
2709 + return $fallback;
2710 + }
2711 +
2712 + $country = strtolower( $body['country_code'] );
2713 +
2714 + // Validate the external API response is a valid 2-letter country code.
2715 + if ( ! preg_match( '/^[a-z]{2}$/', $country ) ) {
2716 + self::srfm_log( $country, 'SRFM geo lookup failed (invalid country code):' );
2717 + set_transient( $cache_key, $fallback, self::get_geo_failure_ttl() );
2718 + return $fallback;
2719 + }
2720 +
2721 + set_transient( $cache_key, $country, DAY_IN_SECONDS );
2722 +
2723 + return $country;
2724 + }
2725 +
2726 + /**
2727 + * Read a visitor country code from a CDN / server geo header, if present.
2728 + *
2729 + * Many hosts sit behind Cloudflare, CloudFront or a geo-aware web server that
2730 + * injects the visitor's country as a request header. This is free, instant,
2731 + * per-visitor and works on full-page-cached sites, so we prefer it over an
2732 + * outbound API call. Returns '' when no usable header is present.
2733 + *
2734 + * NOTE: these headers are client-spoofable when the site is NOT actually behind
2735 + * the named CDN/proxy. The value is used only as a phone-field UI default (the
2736 + * pre-selected flag), never for access control, so spoofing has no security
2737 + * impact here — at worst a visitor sees a different default country.
2738 + *
2739 + * @since 2.11.1
2740 + * @return string Lowercase 2-letter country code, or '' when unavailable.
2741 + */
2742 + private static function get_cdn_country() {
2743 + $headers = [
2744 + 'HTTP_CF_IPCOUNTRY', // Cloudflare.
2745 + 'HTTP_CLOUDFRONT_VIEWER_COUNTRY', // AWS CloudFront.
2746 + 'GEOIP_COUNTRY_CODE', // Apache/Nginx mod_geoip / MaxMind.
2747 + 'HTTP_X_GEO_COUNTRY', // Some CDNs / reverse proxies.
2748 + 'HTTP_X_COUNTRY_CODE', // Some CDNs.
2749 + ];
2750 +
2751 + foreach ( $headers as $header ) {
2752 + if ( empty( $_SERVER[ $header ] ) ) {
2753 + continue;
2754 + }
2755 +
2756 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Validated by the regex below.
2757 + $code = strtolower( trim( sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ) ) );
2758 +
2759 + // Cloudflare sends 'xx' for unknown and 't1' for Tor; reject non-ISO values.
2760 + if ( preg_match( '/^[a-z]{2}$/', $code ) && 'xx' !== $code && 't1' !== $code ) {
2761 + return $code;
2762 + }
2763 + }
2764 +
2765 + /**
2766 + * Filters the CDN-derived visitor country code, before the ipapi.co fallback.
2767 + *
2768 + * Lets sites short-circuit detection with a server-side country code (e.g.
2769 + * from a custom header) without any outbound API call.
2770 + *
2771 + * @since 2.11.1
2772 + *
2773 + * @param string $code Lowercase 2-letter country code, or '' if none found.
2774 + */
2775 + return apply_filters( 'srfm_cdn_country', '' );
2776 + }
2777 +
2778 + /**
2779 + * TTL (in seconds) for caching a failed geo lookup.
2780 + *
2781 + * Short by default so a transient blip (rate-limit, timeout) self-heals on the
2782 + * next visit instead of pinning the fallback country for a full hour, while
2783 + * still preventing per-request retry storms. Filterable via `srfm_geo_failure_ttl`.
2784 + *
2785 + * @since 2.11.1
2786 + * @return int
2787 + */
2788 + private static function get_geo_failure_ttl() {
2789 + return self::get_integer_value( apply_filters( 'srfm_geo_failure_ttl', 5 * MINUTE_IN_SECONDS ) );
2790 + }
2791 +
2792 + /**
2793 + * Caching plugins SureForms recognises, and the guide for each.
2794 + *
2795 + * `path => [ display name, doc slug ]`. An empty slug means there is no
2796 + * plugin-specific guide and the general one applies. Name and slug live in one
2797 + * array on purpose: keyed separately they drift, and a doc link that silently
2798 + * degrades to the generic page is the kind of regression nobody reports.
2799 + *
2800 + * Order is precedence: the first active plugin in this list wins. The six with
2801 + * their own guide are listed first on purpose, so a site running two caching
2802 + * plugins is pointed at the specific guide rather than whichever plugin the
2803 + * old alphabetical order happened to reach first. That flips the winner on a
2804 + * few pairs -- WP Fastest Cache over WP Super Cache, SiteGround Optimizer and
2805 + * Autoptimize over their partners -- and in each case the new winner is the
2806 + * one that has something to say. Reordering this array changes which guide a
2807 + * two-plugin site sees.
2808 + *
2809 + * @since 2.12.7
2810 + * @return array<string,array{0:string,1:string}>
2811 + */
2812 + private static function get_known_caching_plugins() {
2813 + return [
2814 + 'litespeed-cache/litespeed-cache.php' => [ 'LiteSpeed Cache', 'how-to-set-up-sureforms-with-litespeed-cache' ],
2815 + 'wp-rocket/wp-rocket.php' => [ 'WP Rocket', 'how-to-set-up-sureforms-with-wp-rocket' ],
2816 + 'w3-total-cache/w3-total-cache.php' => [ 'W3 Total Cache', 'how-to-set-up-sureforms-with-w3-total-cache' ],
2817 + 'wp-fastest-cache/wpFastestCache.php' => [ 'WP Fastest Cache', 'how-to-set-up-sureforms-with-wp-fastest-cache' ],
2818 + 'sg-cachepress/sg-cachepress.php' => [ 'SiteGround Optimizer', 'how-to-set-up-sureforms-with-siteground-optimizer' ],
2819 + 'autoptimize/autoptimize.php' => [ 'Autoptimize', 'how-to-set-up-sureforms-with-autoptimize' ],
2820 + 'wp-super-cache/wp-cache.php' => [ 'WP Super Cache', '' ],
2821 + 'wp-optimize/wp-optimize.php' => [ 'WP-Optimize', '' ],
2822 + 'cache-enabler/cache-enabler.php' => [ 'Cache Enabler', '' ],
2823 + 'comet-cache/comet-cache.php' => [ 'Comet Cache', '' ],
2824 + 'hummingbird-performance/wp-hummingbird.php' => [ 'Hummingbird', '' ],
2825 + 'breeze/breeze.php' => [ 'Breeze', '' ],
2826 + 'nitropack/main.php' => [ 'NitroPack', '' ],
2827 + 'swift-performance-lite/performance.php' => [ 'Swift Performance Lite', '' ],
2828 + 'wp-cloudflare-page-cache/wp-cloudflare-page-cache.php' => [ 'Super Page Cache', '' ],
2829 + 'flying-press/flying-press.php' => [ 'FlyingPress', '' ],
2830 + 'redis-cache/redis-cache.php' => [ 'Redis Object Cache', '' ],
2831 + 'powered-cache/powered-cache.php' => [ 'Powered Cache', '' ],
2832 + 'docket-cache/docket-cache.php' => [ 'Docket Cache', '' ],
2833 + 'seraphinite-accelerator/plugin_root.php' => [ 'Seraphinite Accelerator', '' ],
2834 + ];
2835 + }
2836 +
2837 + /**
2838 + * The active caching plugin's entry, if there is one.
2839 + *
2840 + * Detection is by plugin path, mirroring is_any_smtp_plugin_active(), including
2841 + * the multisite network-active merge. First match in
2842 + * get_known_caching_plugins() wins; that array's order is the precedence.
2843 + *
2844 + * @since 2.12.7
2845 + * @return array{0:string,1:string}|null Name and doc slug, or null when none is active.
2846 + */
2847 + private static function get_active_caching_plugin_entry() {
2848 + $active_plugins = (array) get_option( 'active_plugins', [] );
2849 +
2850 + // For multisite, merge sitewide active plugins.
2851 + if ( is_multisite() ) {
2852 + $network_plugins = (array) get_site_option( 'active_sitewide_plugins', [] );
2853 + $active_plugins = array_merge( $active_plugins, array_keys( $network_plugins ) );
2854 + }
2855 +
2856 + foreach ( self::get_known_caching_plugins() as $path => $entry ) {
2857 + if ( in_array( $path, $active_plugins, true ) ) {
2858 + return $entry;
2859 + }
2860 + }
2861 +
2862 + return null;
2313 2863 }
2314 2864
2315 2865 }