PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.8
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.8
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 +609 -21 2.7.0 → 2.12.8 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.
@@ -739,8 +877,9 @@
739 877 continue;
740 878 }
741 879 $label = explode( '-lbl-', $key )[1];
742 880 $slug = implode( '-', array_slice( explode( '-', $label ), 1 ) );
881 + $slug = str_replace( ' ', '_', $slug );
743 882
744 883 /**
745 884 * Filters whether a field should be skipped when mapping slugs to submission data.
746 885 *
@@ -1231,8 +1370,19 @@
1231 1370 $slug = is_string( $block['blockName'] ) ? $block['blockName'] : '';
1232 1371
1233 1372 if ( ! empty( $block['attrs']['label'] ) && is_string( $block['attrs']['label'] ) ) {
1234 1373 $slug = sanitize_title( $block['attrs']['label'] );
1374 +
1375 + // If the label contains non-Latin characters (e.g. Japanese, Chinese),
1376 + // sanitize_title() produces a percent-encoded slug like "%e3%83%95%e3%83%aa".
1377 + // These are unstable and break conditional logic field matching.
1378 + // Fall back to the block name to ensure a stable ASCII slug.
1379 + if ( false !== strpos( $slug, '%' ) ) {
1380 + $block_name = is_string( $block['blockName'] ) ? $block['blockName'] : '';
1381 + // Strip the 'srfm/' namespace to match JS-side cleanForSlug() output.
1382 + $block_name = (string) preg_replace( '/^srfm\//', '', $block_name );
1383 + $slug = sanitize_title( $block_name );
1384 + }
1235 1385 }
1236 1386
1237 1387 if ( ! empty( $prefix ) ) {
1238 1388 $slug = $prefix . '-' . $slug;
@@ -1418,12 +1568,43 @@
1418 1568 if ( ! is_array( $utm_args ) ) {
1419 1569 $utm_args = [];
1420 1570 }
1421 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 +
1422 1587 if ( class_exists( 'BSF_UTM_Analytics' ) ) {
1423 1588 $url = \BSF_UTM_Analytics::get_utm_ready_link( $url, 'sureforms', $utm_args );
1424 1589 }
1425 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 +
1426 1607 return esc_url( $url );
1427 1608 }
1428 1609
1429 1610 /**
@@ -1715,9 +1896,9 @@
1715 1896 *
1716 1897 * @return array<mixed>
1717 1898 */
1718 1899 public static function sureforms_get_integration() {
1719 - $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.
1720 1901 $logo_sure_triggers = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/suretriggers.svg' );
1721 1902 $logo_full = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/suretriggers_full.svg' );
1722 1903 $logo_sure_mails = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/suremails.svg' );
1723 1904 $logo_uae = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/uae.svg' );
@@ -1723,10 +1904,20 @@
1723 1904 $logo_uae = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/uae.svg' );
1724 1905 $logo_starter_templates = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/starterTemplates.svg' );
1725 1906 $logo_sure_rank = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/surerank.svg' );
1726 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' );
1727 1909
1728 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 + ],
1729 1920 'sure_contact' => [
1730 1921 'title' => __( 'SureContact', 'sureforms' ),
1731 1922 'singleLineDescription' => __( 'Turn Emails Into Revenue with a CRM Built for Your Website!', 'sureforms' ),
1732 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' ),
@@ -1952,8 +2143,29 @@
1952 2143 return defined( 'SRFM_PRO_VER' );
1953 2144 }
1954 2145
1955 2146 /**
2147 + * Whether SureForms promotional content should be hidden.
2148 + *
2149 + * Covers review requests, cross-sell banners and announcements. The free
2150 + * plugin never hides them on its own; SureForms Pro's Distraction Free mode
2151 + * turns this on through the filter.
2152 + *
2153 + * @since 2.12.8
2154 + * @return bool
2155 + */
2156 + public static function hide_promotions() {
2157 + /**
2158 + * Filter whether SureForms hides its promotional content in wp-admin.
2159 + *
2160 + * @since 2.12.8
2161 + *
2162 + * @param bool $hide Default false.
2163 + */
2164 + return (bool) apply_filters( 'srfm_hide_promotions', false );
2165 + }
2166 +
2167 + /**
1956 2168 * Verifies the request by checking the nonce and user capabilities.
1957 2169 *
1958 2170 * @param string $request_type The type of request, either 'rest' or 'ajax'.
1959 2171 * @param string $nonce_action The action name for the nonce.
@@ -2018,8 +2230,62 @@
2018 2230 return implode( '-', array_slice( explode( '-', explode( '-lbl-', $field_name )[0] ), 0, 2 ) );
2019 2231 }
2020 2232
2021 2233 /**
2234 + * The active caching plugin, if there is one.
2235 + *
2236 + * Caching matters to SureForms because a cached page serves the same HTML to
2237 + * everyone: the submission token is embedded at render time, and an
2238 + * aggressively cached or JS-combining setup can serve a stale token or reorder
2239 + * the scripts a form depends on. This is what surfaces that to the site owner
2240 + * before it turns into "my form stopped working".
2241 + *
2242 + * @since 2.12.6
2243 + * @return string Human-readable plugin name, or '' when none is active.
2244 + */
2245 + public static function get_active_caching_plugin() {
2246 + $entry = self::get_active_caching_plugin_entry();
2247 +
2248 + return null === $entry ? '' : $entry[0];
2249 + }
2250 +
2251 + /**
2252 + * Setup guide for the active caching plugin.
2253 + *
2254 + * Six of the recognised plugins have a guide of their own; the rest, and any
2255 + * site with none detected, get the general one. Sending someone to a page that
2256 + * names the plugin they actually run is the difference between advice they can
2257 + * follow and advice they have to translate.
2258 + *
2259 + * Falls back to the general guide rather than returning nothing, so the notice
2260 + * always has somewhere to send them.
2261 + *
2262 + * @since 2.12.7
2263 + * @param string $medium Placement the link is rendered in, used as utm_medium.
2264 + * Two surfaces show this guide -- the dashboard notice and
2265 + * the onboarding step -- and a shared value would make the
2266 + * two indistinguishable in reporting, which is the whole
2267 + * point of the attribution.
2268 + * @return string Absolute documentation URL.
2269 + */
2270 + public static function get_caching_plugin_doc_url( $medium = 'form_checks_notice' ) {
2271 + $entry = self::get_active_caching_plugin_entry();
2272 + $slug = null === $entry || '' === $entry[1] ? 'how-to-set-up-sureforms-with-caching-plugins' : $entry[1];
2273 +
2274 + // Through the central builder rather than hardcoding the domain, so the
2275 + // link carries the same UTM attribution as every other doc link and a
2276 + // domain change is one edit. utm_content is the slug, so the caller can be
2277 + // told which guide people actually open.
2278 + return self::get_sureforms_website_url(
2279 + 'docs/' . $slug . '/',
2280 + [
2281 + 'utm_medium' => self::get_string_value( $medium ),
2282 + 'utm_content' => $slug,
2283 + ]
2284 + );
2285 + }
2286 +
2287 + /**
2022 2288 * Check if any of the top 10 popular WordPress SMTP plugins is active using array_intersect.
2023 2289 *
2024 2290 * @since 1.9.1
2025 2291 * @return bool True if any SMTP plugin is active, false otherwise.
@@ -2070,9 +2336,9 @@
2070 2336 return $default;
2071 2337 }
2072 2338
2073 2339 // Apply the filter with additional arguments.
2074 - $filtered = apply_filters( $filter_name, $default, ...$args );
2340 + $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.
2075 2341
2076 2342 // Return filtered result if it's a non-empty array.
2077 2343 return is_array( $filtered ) && ! empty( $filtered ) ? $filtered : $default;
2078 2344 }
@@ -2297,7 +2563,329 @@
2297 2563 }
2298 2564
2299 2565 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
2300 2566 return base64_encode( $json );
2567 + }
2568 +
2569 + /**
2570 + * Get the visitor's IP address.
2571 + *
2572 + * Centralised IP detection that checks common proxy headers before
2573 + * falling back to REMOTE_ADDR. Handles comma-separated IPs that
2574 + * load-balancers / CDNs may append (takes the first, i.e. client IP).
2575 + *
2576 + * NOTE: Existing callers (Smart_Tags::get_the_user_ip, Front_End::get_user_ip,
2577 + * inline reads in Form_Submit) can be migrated to this method in the future
2578 + * to avoid duplicating the same header-chain logic.
2579 + *
2580 + * @since 2.8.0
2581 + * @return string Validated IP address, or empty string if unavailable.
2582 + */
2583 + public static function get_visitor_ip() {
2584 + $headers = [
2585 + 'HTTP_CLIENT_IP',
2586 + 'HTTP_X_FORWARDED_FOR',
2587 + 'HTTP_X_REAL_IP',
2588 + 'HTTP_X_FORWARDED',
2589 + 'HTTP_FORWARDED_FOR',
2590 + 'HTTP_FORWARDED',
2591 + 'REMOTE_ADDR',
2592 + ];
2593 +
2594 + foreach ( $headers as $header ) {
2595 + if ( empty( $_SERVER[ $header ] ) ) {
2596 + continue;
2597 + }
2598 +
2599 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Validated by FILTER_VALIDATE_IP below.
2600 + $raw = wp_unslash( $_SERVER[ $header ] );
2601 +
2602 + // Proxies may send comma-separated IPs; the first is the original client.
2603 + if ( false !== strpos( $raw, ',' ) ) {
2604 + $raw = trim( explode( ',', $raw )[0] );
2605 + }
2606 +
2607 + $ip = filter_var( $raw, FILTER_VALIDATE_IP );
2608 + if ( false !== $ip ) {
2609 + /**
2610 + * Filters the detected visitor IP address.
2611 + *
2612 + * @since 2.8.0
2613 + *
2614 + * @param string $ip Validated IP address.
2615 + */
2616 + return apply_filters( 'srfm_visitor_ip', $ip );
2617 + }
2618 + }
2619 +
2620 + return '';
2621 + }
2622 +
2623 + /**
2624 + * Detect the visitor's 2-letter country code via server-side IP geolocation.
2625 + *
2626 + * Prefers a CDN/server-provided country header (Cloudflare, CloudFront, mod_geoip)
2627 + * when present — free, instant and cache-safe. Otherwise calls ipapi.co once per
2628 + * visitor IP and caches the result in a transient for 24 hours so subsequent
2629 + * lookups for the same IP resolve instantly. Failures are cached for a short TTL
2630 + * (see get_geo_failure_ttl()) to avoid retry storms while still self-healing, and
2631 + * a site-wide hourly cap (filterable via `srfm_geo_api_hourly_cap`, default 40)
2632 + * bounds outbound calls. Private/reserved IPs are rejected up front.
2633 + *
2634 + * Intended to be called per-visitor (e.g. via the geo-country REST route) so
2635 + * the result is correct on full-page-cached sites instead of being baked into
2636 + * the cached HTML.
2637 + *
2638 + * Local testing: private/loopback IPs (e.g. 127.0.0.1) cannot be geolocated, so
2639 + * inject a public IP via the `srfm_visitor_ip` filter to exercise detection:
2640 + *
2641 + * add_filter( 'srfm_visitor_ip', static fn() => '8.8.8.8' ); // US; try 1.1.1.1 etc.
2642 + *
2643 + * @param string $fallback Country code returned when detection is unavailable.
2644 + * @since 2.11.1
2645 + * @return string Lowercase 2-letter country code.
2646 + */
2647 + public static function get_geo_country( $fallback = 'us' ) {
2648 + // Prefer a CDN/server-provided country header — free, instant, per-visitor
2649 + // and cache-safe. It is independent of the connecting IP (it still resolves
2650 + // when the visitor IP is private/loopback, e.g. local dev or behind a
2651 + // proxy), so it must be checked before the IP-based path below.
2652 + $cdn_country = self::get_cdn_country();
2653 + if ( '' !== $cdn_country ) {
2654 + return $cdn_country;
2655 + }
2656 +
2657 + $ip = self::get_visitor_ip();
2658 + if ( empty( $ip ) ) {
2659 + return $fallback;
2660 + }
2661 +
2662 + // Reject private/reserved IPs: ipapi.co cannot geolocate them, and accepting
2663 + // them would let spoofed X-Forwarded-For headers flood the transient cache.
2664 + if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
2665 + return $fallback;
2666 + }
2667 +
2668 + // Versioned key (v2) so stale failure transients written by older code
2669 + // (which used the byte-identical 'srfm_geo_' key and a 1h TTL) don't shadow
2670 + // the current CDN/fallback logic for the failure-TTL window after an upgrade.
2671 + $cache_key = 'srfm_geo_v2_' . md5( $ip );
2672 + $cached = get_transient( $cache_key );
2673 + if ( is_string( $cached ) && '' !== $cached ) {
2674 + return $cached;
2675 + }
2676 +
2677 + // Site-wide hourly cap on outbound ipapi calls; the counter rolls over
2678 + // every hour (key includes YmdH) so it never needs an explicit reset.
2679 + $quota_key = 'srfm_geo_quota_' . gmdate( 'YmdH' );
2680 + $quota_cap = self::get_integer_value( apply_filters( 'srfm_geo_api_hourly_cap', 40 ) );
2681 + $count = self::get_integer_value( get_transient( $quota_key ) );
2682 + if ( $count >= $quota_cap ) {
2683 + self::srfm_log( $quota_cap, 'SRFM geo lookup skipped (hourly cap reached):' );
2684 + // Do NOT cache a per-IP transient here: the hourly counter already
2685 + // blocks outbound calls, and writing per IP is the one path not bounded
2686 + // by the cap — it would let spoofed X-Forwarded-For headers churn
2687 + // wp_options / the object cache under sustained traffic.
2688 + return $fallback;
2689 + }
2690 + set_transient( $quota_key, $count + 1, HOUR_IN_SECONDS );
2691 +
2692 + // Pass the visitor's IP explicitly via /{ip}/json/ — the request originates
2693 + // from the server, so the bare /json/ endpoint would return the host's country.
2694 + $url = 'https://ipapi.co/' . rawurlencode( $ip ) . '/json/';
2695 +
2696 + // ipapi.co's free (keyless) tier is heavily rate-limited, so unauthenticated
2697 + // lookups are best-effort and often fail to the configured fallback. Sites
2698 + // that need reliable IP-based detection can supply a paid ipapi.co key via
2699 + // this filter; CDN-fronted sites resolve earlier via get_cdn_country() and
2700 + // never reach this call.
2701 + $api_key = self::get_string_value( apply_filters( 'srfm_ipapi_api_key', '' ) );
2702 + if ( '' !== $api_key ) {
2703 + $url = add_query_arg( 'key', rawurlencode( $api_key ), $url );
2704 + }
2705 +
2706 + $response = wp_remote_get(
2707 + $url,
2708 + [
2709 + 'timeout' => 3,
2710 + 'user-agent' => 'SureForms/' . SRFM_VER . ' (+https://sureforms.com)',
2711 + ]
2712 + );
2713 +
2714 + if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
2715 + $detail = is_wp_error( $response ) ? $response->get_error_message() : wp_remote_retrieve_response_code( $response );
2716 + self::srfm_log( $detail, 'SRFM geo lookup failed (transport):' );
2717 + set_transient( $cache_key, $fallback, self::get_geo_failure_ttl() );
2718 + return $fallback;
2719 + }
2720 +
2721 + $body = json_decode( wp_remote_retrieve_body( $response ), true );
2722 +
2723 + // ipapi.co's free tier returns HTTP 200 with a JSON error body
2724 + // (e.g. {"error":true,"reason":"RateLimited"}) when throttled — treat as a failure.
2725 + if ( is_array( $body ) && ! empty( $body['error'] ) ) {
2726 + $reason = ! empty( $body['reason'] ) && is_string( $body['reason'] ) ? $body['reason'] : 'unknown';
2727 + self::srfm_log( $reason, 'SRFM geo lookup failed (ipapi error):' );
2728 + set_transient( $cache_key, $fallback, self::get_geo_failure_ttl() );
2729 + return $fallback;
2730 + }
2731 +
2732 + if ( ! is_array( $body ) || empty( $body['country_code'] ) || ! is_string( $body['country_code'] ) ) {
2733 + self::srfm_log( wp_remote_retrieve_response_code( $response ), 'SRFM geo lookup failed (no country_code):' );
2734 + set_transient( $cache_key, $fallback, self::get_geo_failure_ttl() );
2735 + return $fallback;
2736 + }
2737 +
2738 + $country = strtolower( $body['country_code'] );
2739 +
2740 + // Validate the external API response is a valid 2-letter country code.
2741 + if ( ! preg_match( '/^[a-z]{2}$/', $country ) ) {
2742 + self::srfm_log( $country, 'SRFM geo lookup failed (invalid country code):' );
2743 + set_transient( $cache_key, $fallback, self::get_geo_failure_ttl() );
2744 + return $fallback;
2745 + }
2746 +
2747 + set_transient( $cache_key, $country, DAY_IN_SECONDS );
2748 +
2749 + return $country;
2750 + }
2751 +
2752 + /**
2753 + * Read a visitor country code from a CDN / server geo header, if present.
2754 + *
2755 + * Many hosts sit behind Cloudflare, CloudFront or a geo-aware web server that
2756 + * injects the visitor's country as a request header. This is free, instant,
2757 + * per-visitor and works on full-page-cached sites, so we prefer it over an
2758 + * outbound API call. Returns '' when no usable header is present.
2759 + *
2760 + * NOTE: these headers are client-spoofable when the site is NOT actually behind
2761 + * the named CDN/proxy. The value is used only as a phone-field UI default (the
2762 + * pre-selected flag), never for access control, so spoofing has no security
2763 + * impact here — at worst a visitor sees a different default country.
2764 + *
2765 + * @since 2.11.1
2766 + * @return string Lowercase 2-letter country code, or '' when unavailable.
2767 + */
2768 + private static function get_cdn_country() {
2769 + $headers = [
2770 + 'HTTP_CF_IPCOUNTRY', // Cloudflare.
2771 + 'HTTP_CLOUDFRONT_VIEWER_COUNTRY', // AWS CloudFront.
2772 + 'GEOIP_COUNTRY_CODE', // Apache/Nginx mod_geoip / MaxMind.
2773 + 'HTTP_X_GEO_COUNTRY', // Some CDNs / reverse proxies.
2774 + 'HTTP_X_COUNTRY_CODE', // Some CDNs.
2775 + ];
2776 +
2777 + foreach ( $headers as $header ) {
2778 + if ( empty( $_SERVER[ $header ] ) ) {
2779 + continue;
2780 + }
2781 +
2782 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Validated by the regex below.
2783 + $code = strtolower( trim( sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ) ) );
2784 +
2785 + // Cloudflare sends 'xx' for unknown and 't1' for Tor; reject non-ISO values.
2786 + if ( preg_match( '/^[a-z]{2}$/', $code ) && 'xx' !== $code && 't1' !== $code ) {
2787 + return $code;
2788 + }
2789 + }
2790 +
2791 + /**
2792 + * Filters the CDN-derived visitor country code, before the ipapi.co fallback.
2793 + *
2794 + * Lets sites short-circuit detection with a server-side country code (e.g.
2795 + * from a custom header) without any outbound API call.
2796 + *
2797 + * @since 2.11.1
2798 + *
2799 + * @param string $code Lowercase 2-letter country code, or '' if none found.
2800 + */
2801 + return apply_filters( 'srfm_cdn_country', '' );
2802 + }
2803 +
2804 + /**
2805 + * TTL (in seconds) for caching a failed geo lookup.
2806 + *
2807 + * Short by default so a transient blip (rate-limit, timeout) self-heals on the
2808 + * next visit instead of pinning the fallback country for a full hour, while
2809 + * still preventing per-request retry storms. Filterable via `srfm_geo_failure_ttl`.
2810 + *
2811 + * @since 2.11.1
2812 + * @return int
2813 + */
2814 + private static function get_geo_failure_ttl() {
2815 + return self::get_integer_value( apply_filters( 'srfm_geo_failure_ttl', 5 * MINUTE_IN_SECONDS ) );
2816 + }
2817 +
2818 + /**
2819 + * Caching plugins SureForms recognises, and the guide for each.
2820 + *
2821 + * `path => [ display name, doc slug ]`. An empty slug means there is no
2822 + * plugin-specific guide and the general one applies. Name and slug live in one
2823 + * array on purpose: keyed separately they drift, and a doc link that silently
2824 + * degrades to the generic page is the kind of regression nobody reports.
2825 + *
2826 + * Order is precedence: the first active plugin in this list wins. The six with
2827 + * their own guide are listed first on purpose, so a site running two caching
2828 + * plugins is pointed at the specific guide rather than whichever plugin the
2829 + * old alphabetical order happened to reach first. That flips the winner on a
2830 + * few pairs -- WP Fastest Cache over WP Super Cache, SiteGround Optimizer and
2831 + * Autoptimize over their partners -- and in each case the new winner is the
2832 + * one that has something to say. Reordering this array changes which guide a
2833 + * two-plugin site sees.
2834 + *
2835 + * @since 2.12.7
2836 + * @return array<string,array{0:string,1:string}>
2837 + */
2838 + private static function get_known_caching_plugins() {
2839 + return [
2840 + 'litespeed-cache/litespeed-cache.php' => [ 'LiteSpeed Cache', 'how-to-set-up-sureforms-with-litespeed-cache' ],
2841 + 'wp-rocket/wp-rocket.php' => [ 'WP Rocket', 'how-to-set-up-sureforms-with-wp-rocket' ],
2842 + 'w3-total-cache/w3-total-cache.php' => [ 'W3 Total Cache', 'how-to-set-up-sureforms-with-w3-total-cache' ],
2843 + 'wp-fastest-cache/wpFastestCache.php' => [ 'WP Fastest Cache', 'how-to-set-up-sureforms-with-wp-fastest-cache' ],
2844 + 'sg-cachepress/sg-cachepress.php' => [ 'SiteGround Optimizer', 'how-to-set-up-sureforms-with-siteground-optimizer' ],
2845 + 'autoptimize/autoptimize.php' => [ 'Autoptimize', 'how-to-set-up-sureforms-with-autoptimize' ],
2846 + 'wp-super-cache/wp-cache.php' => [ 'WP Super Cache', '' ],
2847 + 'wp-optimize/wp-optimize.php' => [ 'WP-Optimize', '' ],
2848 + 'cache-enabler/cache-enabler.php' => [ 'Cache Enabler', '' ],
2849 + 'comet-cache/comet-cache.php' => [ 'Comet Cache', '' ],
2850 + 'hummingbird-performance/wp-hummingbird.php' => [ 'Hummingbird', '' ],
2851 + 'breeze/breeze.php' => [ 'Breeze', '' ],
2852 + 'nitropack/main.php' => [ 'NitroPack', '' ],
2853 + 'swift-performance-lite/performance.php' => [ 'Swift Performance Lite', '' ],
2854 + 'wp-cloudflare-page-cache/wp-cloudflare-page-cache.php' => [ 'Super Page Cache', '' ],
2855 + 'flying-press/flying-press.php' => [ 'FlyingPress', '' ],
2856 + 'redis-cache/redis-cache.php' => [ 'Redis Object Cache', '' ],
2857 + 'powered-cache/powered-cache.php' => [ 'Powered Cache', '' ],
2858 + 'docket-cache/docket-cache.php' => [ 'Docket Cache', '' ],
2859 + 'seraphinite-accelerator/plugin_root.php' => [ 'Seraphinite Accelerator', '' ],
2860 + ];
2861 + }
2862 +
2863 + /**
2864 + * The active caching plugin's entry, if there is one.
2865 + *
2866 + * Detection is by plugin path, mirroring is_any_smtp_plugin_active(), including
2867 + * the multisite network-active merge. First match in
2868 + * get_known_caching_plugins() wins; that array's order is the precedence.
2869 + *
2870 + * @since 2.12.7
2871 + * @return array{0:string,1:string}|null Name and doc slug, or null when none is active.
2872 + */
2873 + private static function get_active_caching_plugin_entry() {
2874 + $active_plugins = (array) get_option( 'active_plugins', [] );
2875 +
2876 + // For multisite, merge sitewide active plugins.
2877 + if ( is_multisite() ) {
2878 + $network_plugins = (array) get_site_option( 'active_sitewide_plugins', [] );
2879 + $active_plugins = array_merge( $active_plugins, array_keys( $network_plugins ) );
2880 + }
2881 +
2882 + foreach ( self::get_known_caching_plugins() as $path => $entry ) {
2883 + if ( in_array( $path, $active_plugins, true ) ) {
2884 + return $entry;
2885 + }
2886 + }
2887 +
2888 + return null;
2301 2889 }
2302 2890
2303 2891 }