| @@ -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 | } |