| @@ -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 ). |
| @@ -411,9 +423,9 @@ | ||
| 411 | 423 | if ( $label ) { |
| 412 | 424 | ob_start(); |
| 413 | 425 | ?> |
| 414 | 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"> |
| 415 | - <?php echo wp_kses_post( $label ); ?> | |
| 427 | + <?php echo esc_html( $label ); ?> | |
| 416 | 428 | <?php if ( $required ) { ?> |
| 417 | 429 | <span class="srfm-required" aria-hidden="true"> *</span> |
| 418 | 430 | <?php } ?> |
| 419 | 431 | </label> |
| @@ -425,9 +437,9 @@ | ||
| 425 | 437 | if ( $help ) { |
| 426 | 438 | ob_start(); |
| 427 | 439 | ?> |
| 428 | 440 | <div class="srfm-description" id="srfm-description-<?php echo esc_attr( $block_id ); ?>"> |
| 429 | - <?php echo wp_kses_post( $help ); ?> | |
| 441 | + <?php echo esc_html( $help ); ?> | |
| 430 | 442 | </div> |
| 431 | 443 | <?php |
| 432 | 444 | $markup = ob_get_clean(); |
| 433 | 445 | } |
| @@ -454,9 +466,9 @@ | ||
| 454 | 466 | $markup = ob_get_clean(); |
| 455 | 467 | } |
| 456 | 468 | break; |
| 457 | 469 | case 'placeholder': |
| 458 | - $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 ) : '' ) : ''; | |
| 459 | 471 | break; |
| 460 | 472 | case 'label_text': |
| 461 | 473 | // This has been added for generating label text for the form markup instead of adding it in the label tag. |
| 462 | 474 | if ( $label ) { |
| @@ -461,9 +473,9 @@ | ||
| 461 | 473 | // This has been added for generating label text for the form markup instead of adding it in the label tag. |
| 462 | 474 | if ( $label ) { |
| 463 | 475 | ob_start(); |
| 464 | 476 | ?> |
| 465 | - <?php echo wp_kses_post( $label ); ?> | |
| 477 | + <?php echo esc_html( $label ); ?> | |
| 466 | 478 | <?php if ( $required ) { ?> |
| 467 | 479 | <span class="srfm-required" aria-hidden="true"> *</span> |
| 468 | 480 | <?php } ?> |
| 469 | 481 | <?php |
| @@ -507,15 +519,34 @@ | ||
| 507 | 519 | return is_string( $output ) ? $output : ''; |
| 508 | 520 | } |
| 509 | 521 | |
| 510 | 522 | /** |
| 511 | - * Encrypt data using base64. | |
| 523 | + * Base64-encode a string for use inside a field key. | |
| 512 | 524 | * |
| 513 | - * @param string $input The input string which needs to be encrypted. | |
| 514 | - * @since 0.0.1 | |
| 515 | - * @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). | |
| 516 | 547 | */ |
| 517 | - public static function encrypt( $input ) { | |
| 548 | + public static function encode( $input ) { | |
| 518 | 549 | // If the input is empty or not a string, then abandon ship. |
| 519 | 550 | if ( empty( $input ) || ! is_string( $input ) ) { |
| 520 | 551 | return ''; |
| 521 | 552 | } |
| @@ -522,32 +553,67 @@ | ||
| 522 | 553 | |
| 523 | 554 | // Strip HTML tags to prevent them from being included in IDs and field names. |
| 524 | 555 | $input = wp_strip_all_tags( $input ); |
| 525 | 556 | |
| 526 | - // Encrypt the input and return it. | |
| 557 | + // Base64-encode the input and return it. | |
| 527 | 558 | $base_64 = base64_encode( $input ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode |
| 528 | 559 | return rtrim( $base_64, '=' ); |
| 529 | 560 | } |
| 530 | 561 | |
| 531 | 562 | /** |
| 532 | - * Decrypt data using base64. | |
| 563 | + * Base64-encode a string. | |
| 533 | 564 | * |
| 534 | - * @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. | |
| 535 | 569 | * @since 0.0.1 |
| 536 | - * @return string The decrypted string. | |
| 570 | + * @return string The base64-encoded string. | |
| 537 | 571 | */ |
| 538 | - 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 ) { | |
| 539 | 591 | // If the input is empty or not a string, then abandon ship. |
| 540 | 592 | if ( empty( $input ) || ! is_string( $input ) ) { |
| 541 | 593 | return ''; |
| 542 | 594 | } |
| 543 | 595 | |
| 544 | - // Decrypt the input and return it. | |
| 596 | + // Base64-decode the input and return it. | |
| 545 | 597 | $base_64 = $input . str_repeat( '=', strlen( $input ) % 4 ); |
| 546 | 598 | return base64_decode( $base_64 ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode |
| 547 | 599 | } |
| 548 | 600 | |
| 549 | 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 | + /** | |
| 550 | 616 | * Update an option from the database. |
| 551 | 617 | * |
| 552 | 618 | * @param string $key The option key. |
| 553 | 619 | * @param mixed $value The value to update. |
| @@ -683,8 +749,43 @@ | ||
| 683 | 749 | ); |
| 684 | 750 | } |
| 685 | 751 | |
| 686 | 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 | + /** | |
| 687 | 788 | * Check if the current user has a given capability. |
| 688 | 789 | * |
| 689 | 790 | * @param string $capability The capability to check. |
| 690 | 791 | * @param array<mixed> $args Optional. Additional arguments to pass to the capability check. |
| @@ -1467,12 +1568,43 @@ | ||
| 1467 | 1568 | if ( ! is_array( $utm_args ) ) { |
| 1468 | 1569 | $utm_args = []; |
| 1469 | 1570 | } |
| 1470 | 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 | + | |
| 1471 | 1587 | if ( class_exists( 'BSF_UTM_Analytics' ) ) { |
| 1472 | 1588 | $url = \BSF_UTM_Analytics::get_utm_ready_link( $url, 'sureforms', $utm_args ); |
| 1473 | 1589 | } |
| 1474 | 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 | + | |
| 1475 | 1607 | return esc_url( $url ); |
| 1476 | 1608 | } |
| 1477 | 1609 | |
| 1478 | 1610 | /** |
| @@ -1764,9 +1896,9 @@ | ||
| 1764 | 1896 | * |
| 1765 | 1897 | * @return array<mixed> |
| 1766 | 1898 | */ |
| 1767 | 1899 | public static function sureforms_get_integration() { |
| 1768 | - $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. | |
| 1769 | 1901 | $logo_sure_triggers = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/suretriggers.svg' ); |
| 1770 | 1902 | $logo_full = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/suretriggers_full.svg' ); |
| 1771 | 1903 | $logo_sure_mails = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/suremails.svg' ); |
| 1772 | 1904 | $logo_uae = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/uae.svg' ); |
| @@ -1772,10 +1904,20 @@ | ||
| 1772 | 1904 | $logo_uae = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/uae.svg' ); |
| 1773 | 1905 | $logo_starter_templates = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/starterTemplates.svg' ); |
| 1774 | 1906 | $logo_sure_rank = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/surerank.svg' ); |
| 1775 | 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' ); | |
| 1776 | 1909 | |
| 1777 | 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 | + ], | |
| 1778 | 1920 | 'sure_contact' => [ |
| 1779 | 1921 | 'title' => __( 'SureContact', 'sureforms' ), |
| 1780 | 1922 | 'singleLineDescription' => __( 'Turn Emails Into Revenue with a CRM Built for Your Website!', 'sureforms' ), |
| 1781 | 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' ), |
| @@ -2001,8 +2143,29 @@ | ||
| 2001 | 2143 | return defined( 'SRFM_PRO_VER' ); |
| 2002 | 2144 | } |
| 2003 | 2145 | |
| 2004 | 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 | + /** | |
| 2005 | 2168 | * Verifies the request by checking the nonce and user capabilities. |
| 2006 | 2169 | * |
| 2007 | 2170 | * @param string $request_type The type of request, either 'rest' or 'ajax'. |
| 2008 | 2171 | * @param string $nonce_action The action name for the nonce. |
| @@ -2067,8 +2230,62 @@ | ||
| 2067 | 2230 | return implode( '-', array_slice( explode( '-', explode( '-lbl-', $field_name )[0] ), 0, 2 ) ); |
| 2068 | 2231 | } |
| 2069 | 2232 | |
| 2070 | 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 | + /** | |
| 2071 | 2288 | * Check if any of the top 10 popular WordPress SMTP plugins is active using array_intersect. |
| 2072 | 2289 | * |
| 2073 | 2290 | * @since 1.9.1 |
| 2074 | 2291 | * @return bool True if any SMTP plugin is active, false otherwise. |
| @@ -2119,9 +2336,9 @@ | ||
| 2119 | 2336 | return $default; |
| 2120 | 2337 | } |
| 2121 | 2338 | |
| 2122 | 2339 | // Apply the filter with additional arguments. |
| 2123 | - $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. | |
| 2124 | 2341 | |
| 2125 | 2342 | // Return filtered result if it's a non-empty array. |
| 2126 | 2343 | return is_array( $filtered ) && ! empty( $filtered ) ? $filtered : $default; |
| 2127 | 2344 | } |
| @@ -2400,7 +2617,275 @@ | ||
| 2400 | 2617 | } |
| 2401 | 2618 | } |
| 2402 | 2619 | |
| 2403 | 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; | |
| 2404 | 2889 | } |
| 2405 | 2890 | |
| 2406 | 2891 | } |