From 4be694d174cf9c830c44bd230ef316ab3f67c317 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Wed, 29 Jul 2026 10:45:57 +0800 Subject: [PATCH 1/7] Spam Protection: Cloudflare Turnstile --- .env.example | 2 + ...class-convertkit-admin-section-general.php | 152 +++++++++++++++-- .../class-convertkit-block-form-builder.php | 53 +++--- .../class-convertkit-cloudflare-turnstile.php | 154 ++++++++++++++++++ ...ass-convertkit-output-restrict-content.php | 22 ++- includes/class-convertkit-settings.php | 86 ++++++++++ includes/class-convertkit-spam-protection.php | 142 ++++++++++++++++ resources/frontend/js/convertkit.js | 34 ++++ .../PluginSettingsGeneralCest.php | 77 +++++++++ views/frontend/restrict-content/tag.php | 16 +- wp-convertkit.php | 2 + 11 files changed, 694 insertions(+), 46 deletions(-) create mode 100644 includes/class-convertkit-cloudflare-turnstile.php create mode 100644 includes/class-convertkit-spam-protection.php diff --git a/.env.example b/.env.example index 7b919f2e8..c694caedb 100644 --- a/.env.example +++ b/.env.example @@ -28,3 +28,5 @@ CONVERTKIT_API_SIGNED_SUBSCRIBER_ID= CONVERTKIT_API_SIGNED_SUBSCRIBER_ID_NO_ACCESS= CONVERTKIT_API_RECAPTCHA_SITE_KEY= CONVERTKIT_API_RECAPTCHA_SECRET_KEY= +CONVERTKIT_TURNSTILE_SITE_KEY= +CONVERTKIT_TURNSTILE_SECRET_KEY= diff --git a/admin/section/class-convertkit-admin-section-general.php b/admin/section/class-convertkit-admin-section-general.php index dc10bbb5b..e1dd7beaf 100644 --- a/admin/section/class-convertkit-admin-section-general.php +++ b/admin/section/class-convertkit-admin-section-general.php @@ -69,9 +69,9 @@ public function __construct() { 'callback' => array( $this, 'print_section_info_site_wide' ), 'wrap' => true, ), - 'recaptcha' => array( - 'title' => __( 'reCAPTCHA', 'convertkit' ), - 'callback' => array( $this, 'print_section_info_recaptcha' ), + 'spam-protection' => array( + 'title' => __( 'Spam Protection', 'convertkit' ), + 'callback' => array( $this, 'print_section_info_spam_protection' ), 'wrap' => true, ), 'advanced' => array( @@ -276,6 +276,28 @@ public function enqueue_scripts( $section ) { wp_enqueue_script( 'convertkit-admin-preview-output', CONVERTKIT_PLUGIN_URL . 'resources/backend/js/preview-output.js', array( 'jquery' ), CONVERTKIT_PLUGIN_VERSION, true ); wp_enqueue_script( 'convertkit-admin-settings-conditional-display', CONVERTKIT_PLUGIN_URL . 'resources/backend/js/settings-conditional-display.js', array( 'jquery' ), CONVERTKIT_PLUGIN_VERSION, true ); + // Inline: show/hide provider-specific rows based on the Spam Protection provider dropdown. + wp_add_inline_script( + 'convertkit-admin-settings-conditional-display', + 'document.addEventListener("DOMContentLoaded", function () { + var providerSelect = document.getElementById("spam_protection_provider"); + if ( ! providerSelect ) { return; } + function convertKitToggleSpamProtectionRows() { + var value = providerSelect.value; + var rows = document.querySelectorAll("tr.convertkit-spam-protection-recaptcha, tr.convertkit-spam-protection-turnstile"); + rows.forEach(function ( row ) { + if ( row.classList.contains("convertkit-spam-protection-" + value) ) { + row.style.display = ""; + } else { + row.style.display = "none"; + } + }); + } + providerSelect.addEventListener("change", convertKitToggleSpamProtectionRows); + convertKitToggleSpamProtectionRows(); + });' + ); + } /** @@ -409,15 +431,31 @@ public function register_fields() { ) ); + // Spam Protection. + add_settings_field( + 'spam_protection_provider', + __( 'Provider', 'convertkit' ), + array( $this, 'spam_protection_provider_callback' ), + $this->settings_key, + $this->name . '-spam-protection', + array( + 'label_for' => 'spam_protection_provider', + 'description' => array( + __( 'Select which spam protection service to use for the Member Content signup form and Form Builder block.', 'convertkit' ), + ), + ) + ); + // reCAPTCHA. add_settings_field( 'recaptcha_site_key', __( 'reCAPTCHA: Site Key', 'convertkit' ), array( $this, 'recaptcha_site_key_callback' ), $this->settings_key, - $this->name . '-recaptcha', + $this->name . '-spam-protection', array( 'label_for' => 'recaptcha_site_key', + 'class' => 'convertkit-spam-protection-recaptcha', 'description' => array( __( 'Enter your Google reCAPTCHA v3 Site Key. When specified, this will be used to reduce spam signups.', 'convertkit' ), ), @@ -428,9 +466,10 @@ public function register_fields() { __( 'reCAPTCHA: Secret Key', 'convertkit' ), array( $this, 'recaptcha_secret_key_callback' ), $this->settings_key, - $this->name . '-recaptcha', + $this->name . '-spam-protection', array( 'label_for' => 'recaptcha_secret_key', + 'class' => 'convertkit-spam-protection-recaptcha', 'description' => array( __( 'Enter your Google reCAPTCHA v3 Secret Key. When specified, this will be used to reduce spam signups.', 'convertkit' ), ), @@ -441,9 +480,10 @@ public function register_fields() { __( 'reCAPTCHA: Minimum Score', 'convertkit' ), array( $this, 'recaptcha_minimum_score_callback' ), $this->settings_key, - $this->name . '-recaptcha', + $this->name . '-spam-protection', array( 'label_for' => 'recaptcha_minimum_score', + 'class' => 'convertkit-spam-protection-recaptcha', 'min' => 0, 'max' => 1, 'step' => 0.01, @@ -453,6 +493,36 @@ public function register_fields() { ) ); + // Cloudflare Turnstile. + add_settings_field( + 'turnstile_site_key', + __( 'Turnstile: Site Key', 'convertkit' ), + array( $this, 'turnstile_site_key_callback' ), + $this->settings_key, + $this->name . '-spam-protection', + array( + 'label_for' => 'turnstile_site_key', + 'class' => 'convertkit-spam-protection-turnstile', + 'description' => array( + __( 'Enter your Cloudflare Turnstile Site Key. When specified, this will be used to reduce spam signups.', 'convertkit' ), + ), + ) + ); + add_settings_field( + 'turnstile_secret_key', + __( 'Turnstile: Secret Key', 'convertkit' ), + array( $this, 'turnstile_secret_key_callback' ), + $this->settings_key, + $this->name . '-spam-protection', + array( + 'label_for' => 'turnstile_secret_key', + 'class' => 'convertkit-spam-protection-turnstile', + 'description' => array( + __( 'Enter your Cloudflare Turnstile Secret Key. When specified, this will be used to reduce spam signups.', 'convertkit' ), + ), + ) + ); + // Advanced. add_settings_field( 'debug', @@ -547,13 +617,13 @@ public function print_section_info_site_wide() { } /** - * Prints help info for the recaptcha section of the settings screen. + * Prints help info for the spam protection section of the settings screen. * - * @since 3.0.0 + * @since 3.4.0 */ - public function print_section_info_recaptcha() { + public function print_section_info_spam_protection() { ?> -

+

output_select_field( + 'spam_protection_provider', + esc_attr( $this->settings->spam_protection_provider() ), + array( + 'none' => esc_html__( 'None', 'convertkit' ), + 'recaptcha' => esc_html__( 'Google reCAPTCHA v3', 'convertkit' ), + 'turnstile' => esc_html__( 'Cloudflare Turnstile', 'convertkit' ), + ), + $args['description'] + ); + + } + /** * Renders the input for the reCAPTCHA Site Key setting. * @@ -985,6 +1077,46 @@ public function recaptcha_minimum_score_callback( $args ) { } + /** + * Renders the input for the Cloudflare Turnstile Site Key setting. + * + * @since 3.4.0 + * + * @param array $args Setting field arguments (name,description). + */ + public function turnstile_site_key_callback( $args ) { + + $this->output_text_field( + 'turnstile_site_key', + esc_attr( $this->settings->turnstile_site_key() ), + $args['description'], + array( + 'widefat', + ) + ); + + } + + /** + * Renders the input for the Cloudflare Turnstile Secret Key setting. + * + * @since 3.4.0 + * + * @param array $args Setting field arguments (name,description). + */ + public function turnstile_secret_key_callback( $args ) { + + $this->output_text_field( + 'turnstile_secret_key', + esc_attr( $this->settings->turnstile_secret_key() ), + $args['description'], + array( + 'widefat', + ) + ); + + } + /** * Renders the input for the Debug setting. * diff --git a/includes/blocks/class-convertkit-block-form-builder.php b/includes/blocks/class-convertkit-block-form-builder.php index 16833a0fd..7c2dbcb94 100644 --- a/includes/blocks/class-convertkit-block-form-builder.php +++ b/includes/blocks/class-convertkit-block-form-builder.php @@ -77,15 +77,11 @@ public function maybe_subscribe() { return; } - // Check reCAPTCHA. - $recaptcha = new ConvertKit_Recaptcha(); - $recaptcha_response = $recaptcha->verify_recaptcha( - ( isset( $_POST['g-recaptcha-response'] ) ? sanitize_text_field( wp_unslash( $_POST['g-recaptcha-response'] ) ) : '' ), - 'convertkit_form_builder' - ); + // Check spam protection (reCAPTCHA or Cloudflare Turnstile, depending on Plugin settings). + $spam_check = ConvertKit_Spam_Protection::verify( 'convertkit_form_builder' ); - // Bail if reCAPTCHA failed. - if ( is_wp_error( $recaptcha_response ) ) { + // Bail if spam protection failed. + if ( is_wp_error( $spam_check ) ) { return; } @@ -721,23 +717,36 @@ public function render_form_button( $block_content, $block ) { $block_content ); - // Return the button if reCAPTCHA does not need to be used. - $settings = new ConvertKit_Settings(); - if ( ! $settings->has_recaptcha_site_and_secret_keys() ) { + // Return the button as-is if no spam protection provider is active. + $provider = ConvertKit_Spam_Protection::get_active_provider(); + if ( $provider === null ) { return $block_content; } - // Enqueue reCAPTCHA JS. - $recaptcha = new ConvertKit_Recaptcha(); - $recaptcha->enqueue_scripts(); - - // Add reCAPTCHA attributes to button. - $parser = new ConvertKit_HTML_Parser( $block_content ); - $button = $parser->xpath->query( '//button' )->item( 0 ); - $button->setAttribute( 'data-sitekey', esc_attr( $settings->recaptcha_site_key() ) ); // @phpstan-ignore-line - $button->setAttribute( 'data-callback', 'convertKitRecaptchaFormSubmit' ); // @phpstan-ignore-line - $button->setAttribute( 'data-action', 'convertkit_form_builder' ); // @phpstan-ignore-line - $button->setAttribute( 'class', trim( $button->getAttribute( 'class' ) . ' g-recaptcha' ) ); // @phpstan-ignore-line + // Enqueue the provider's client-side script. + $provider->enqueue_scripts(); + + $settings = new ConvertKit_Settings(); + $parser = new ConvertKit_HTML_Parser( $block_content ); + $button = $parser->xpath->query( '//button' )->item( 0 ); + + if ( $provider instanceof ConvertKit_Cloudflare_Turnstile ) { + // Prepend a Turnstile widget div immediately before the button. + // interaction-only appearance keeps the widget invisible unless + // Cloudflare determines a challenge is required. + $widget = $parser->html->createElement( 'div' ); + $widget->setAttribute( 'class', 'cf-turnstile' ); + $widget->setAttribute( 'data-sitekey', esc_attr( $settings->turnstile_site_key() ) ); + $widget->setAttribute( 'data-appearance', 'interaction-only' ); + $widget->setAttribute( 'data-callback', 'convertKitTurnstileFormSubmit' ); + $button->parentNode->insertBefore( $widget, $button ); // @phpstan-ignore-line phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + } else { + // reCAPTCHA v3 invisible: attach attributes to the submit button. + $button->setAttribute( 'data-sitekey', esc_attr( $settings->recaptcha_site_key() ) ); // @phpstan-ignore-line + $button->setAttribute( 'data-callback', 'convertKitRecaptchaFormSubmit' ); // @phpstan-ignore-line + $button->setAttribute( 'data-action', 'convertkit_form_builder' ); // @phpstan-ignore-line + $button->setAttribute( 'class', trim( $button->getAttribute( 'class' ) . ' g-recaptcha' ) ); // @phpstan-ignore-line + } // Return button HTML. return $parser->get_body_html(); diff --git a/includes/class-convertkit-cloudflare-turnstile.php b/includes/class-convertkit-cloudflare-turnstile.php new file mode 100644 index 000000000..1bfe8367b --- /dev/null +++ b/includes/class-convertkit-cloudflare-turnstile.php @@ -0,0 +1,154 @@ +settings = new ConvertKit_Settings(); + + } + + /** + * Enqueues the Turnstile client-side script if Turnstile site and secret keys are set, + * and scripts are enabled. + * + * @since 3.4.0 + */ + public function enqueue_scripts() { + + // Don't run if Turnstile or scripts are disabled. + if ( ! $this->settings->has_turnstile_site_and_secret_keys() || $this->settings->scripts_disabled() ) { + return; + } + + // Enqueue Cloudflare Turnstile JS. + add_filter( + 'convertkit_output_scripts_footer', + function ( $scripts ) { + + $scripts[] = array( + 'src' => self::CLIENT_SCRIPT_URL, + 'async' => true, + 'defer' => true, + ); + + return $scripts; + + } + ); + + } + + /** + * Verifies a Turnstile response token against the Siteverify API, if Turnstile + * site and secret keys are set, and scripts are enabled. + * + * Mirrors the request format documented at + * https://developers.cloudflare.com/turnstile/get-started/server-side-validation/ + * + * @since 3.4.0 + * + * @param string $turnstile_response The Turnstile response token from the client. + * @return bool|WP_Error True on success or when Turnstile is not + * configured / disabled; WP_Error on failure. + */ + public function verify( $turnstile_response ) { + + // Don't run if Turnstile or scripts are disabled. + if ( ! $this->settings->has_turnstile_site_and_secret_keys() || $this->settings->scripts_disabled() ) { + return true; + } + + // POST to Cloudflare Siteverify. + $response = wp_remote_post( + self::SITEVERIFY_URL, + array( + 'body' => array( + 'secret' => $this->settings->turnstile_secret_key(), + 'response' => $turnstile_response, + 'remoteip' => ( isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '' ), + ), + ) + ); + + // Bail if the request itself errored. + if ( is_wp_error( $response ) ) { + return $response; + } + + // Decode response. + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + // If the response body couldn't be decoded, treat that as a failure. + if ( ! is_array( $body ) ) { + return new WP_Error( + 'convertkit_turnstile_failed', + __( 'Cloudflare Turnstile failure: invalid response from Siteverify.', 'convertkit' ) + ); + } + + // Cloudflare returns { success: bool, "error-codes": [...] }. + if ( empty( $body['success'] ) ) { + $error_codes = ( isset( $body['error-codes'] ) && is_array( $body['error-codes'] ) ) + ? $body['error-codes'] + : array( 'unknown-error' ); + + return new WP_Error( + 'convertkit_turnstile_failed', + sprintf( + /* translators: Error codes */ + __( 'Cloudflare Turnstile failure: %s', 'convertkit' ), + implode( ', ', $error_codes ) + ) + ); + } + + // Token verified. + return true; + + } + +} diff --git a/includes/class-convertkit-output-restrict-content.php b/includes/class-convertkit-output-restrict-content.php index e43883449..8e48d2370 100644 --- a/includes/class-convertkit-output-restrict-content.php +++ b/includes/class-convertkit-output-restrict-content.php @@ -392,16 +392,12 @@ public function maybe_run_subscriber_authentication() { // If Restrict Content is by tag, tag the subscriber. if ( $this->resource_type === 'tag' ) { - // Check reCAPTCHA. - $recaptcha = new ConvertKit_Recaptcha(); - $recaptcha_response = $recaptcha->verify_recaptcha( - ( isset( $_POST['g-recaptcha-response'] ) ? sanitize_text_field( wp_unslash( $_POST['g-recaptcha-response'] ) ) : '' ), - 'convertkit_restrict_content_tag' - ); + // Check spam protection (reCAPTCHA or Cloudflare Turnstile, depending on Plugin settings). + $spam_check = ConvertKit_Spam_Protection::verify( 'convertkit_restrict_content_tag' ); - // Bail if reCAPTCHA failed. - if ( is_wp_error( $recaptcha_response ) ) { - $this->error = $recaptcha_response; + // Bail if spam protection failed. + if ( is_wp_error( $spam_check ) ) { + $this->error = $spam_check; return; } @@ -1469,9 +1465,11 @@ function () use ( $post_id, $resource_id, $resource_type ) { ); } - // Enqueue Google reCAPTCHA JS. - $recaptcha = new ConvertKit_Recaptcha(); - $recaptcha->enqueue_scripts(); + // Enqueue the active spam protection provider's client-side script. + $spam_provider = ConvertKit_Spam_Protection::get_active_provider(); + if ( $spam_provider !== null ) { + $spam_provider->enqueue_scripts(); + } // Output. ob_start(); diff --git a/includes/class-convertkit-settings.php b/includes/class-convertkit-settings.php index 13c5234b2..d262ac9ba 100644 --- a/includes/class-convertkit-settings.php +++ b/includes/class-convertkit-settings.php @@ -424,6 +424,19 @@ public function non_inline_form_limit_per_session() { } + /** + * Returns the active spam protection provider Plugin setting. + * + * @since 3.4.0 + * + * @return string none|recaptcha|turnstile + */ + public function spam_protection_provider() { + + return $this->settings['spam_protection_provider']; + + } + /** * Returns the reCAPTCHA Site Key Plugin setting. * @@ -503,6 +516,72 @@ public function recaptcha_minimum_score() { } + /** + * Returns the Cloudflare Turnstile Site Key Plugin setting. + * + * @since 3.4.0 + * + * @return string + */ + public function turnstile_site_key() { + + return $this->settings['turnstile_site_key']; + + } + + /** + * Returns whether the Cloudflare Turnstile Site Key has been set in the Plugin settings. + * + * @since 3.4.0 + * + * @return bool + */ + public function has_turnstile_site_key() { + + return ! empty( $this->turnstile_site_key() ); + + } + + /** + * Returns the Cloudflare Turnstile Secret Key Plugin setting. + * + * @since 3.4.0 + * + * @return string + */ + public function turnstile_secret_key() { + + return $this->settings['turnstile_secret_key']; + + } + + /** + * Returns whether the Cloudflare Turnstile Secret Key has been set in the Plugin settings. + * + * @since 3.4.0 + * + * @return bool + */ + public function has_turnstile_secret_key() { + + return ! empty( $this->turnstile_secret_key() ); + + } + + /** + * Returns whether the Cloudflare Turnstile Site Key and Secret Key are defined + * in the Plugin settings. + * + * @since 3.4.0 + * + * @return bool + */ + public function has_turnstile_site_and_secret_keys() { + + return $this->has_turnstile_site_key() && $this->has_turnstile_secret_key(); + + } + /** * Returns whether debugging is enabled in the Plugin settings. * @@ -593,11 +672,18 @@ public function get_defaults() { 'non_inline_form_honor_none_setting' => '', // blank|on. 'non_inline_form_limit_per_session' => '', // blank|on. + // Spam Protection. + 'spam_protection_provider' => 'recaptcha', // none|recaptcha|turnstile. + // reCAPTCHA. 'recaptcha_site_key' => '', // string. 'recaptcha_secret_key' => '', // string. 'recaptcha_minimum_score' => 0.5, // float. + // Cloudflare Turnstile. + 'turnstile_site_key' => '', // string. + 'turnstile_secret_key' => '', // string. + // Advanced. 'debug' => '', // blank|on. 'no_scripts' => '', // blank|on. diff --git a/includes/class-convertkit-spam-protection.php b/includes/class-convertkit-spam-protection.php new file mode 100644 index 000000000..105496b54 --- /dev/null +++ b/includes/class-convertkit-spam-protection.php @@ -0,0 +1,142 @@ +spam_protection_provider() ) { + case 'turnstile': + if ( ! $settings->has_turnstile_site_and_secret_keys() ) { + return null; + } + return new ConvertKit_Cloudflare_Turnstile(); + + case 'recaptcha': + if ( ! $settings->has_recaptcha_site_and_secret_keys() ) { + return null; + } + return new ConvertKit_Recaptcha(); + + case 'none': + default: + return null; + } + + } + + /** + * Returns the POST field name the active provider uses for its challenge + * response, or an empty string if no provider is active. + * + * @since 3.4.0 + * + * @return string + */ + public static function response_field_name() { + + $settings = new ConvertKit_Settings(); + + switch ( $settings->spam_protection_provider() ) { + case 'turnstile': + return 'cf-turnstile-response'; + + case 'recaptcha': + return 'g-recaptcha-response'; + + case 'none': + default: + return ''; + } + + } + + /** + * Reads and sanitizes the challenge response from $_POST for the active + * provider. Returns an empty string if no provider is active or the field + * is absent. + * + * @since 3.4.0 + * + * @return string + */ + public static function get_response_from_post() { + + $field = self::response_field_name(); + + if ( $field === '' ) { + return ''; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Missing + if ( ! isset( $_POST[ $field ] ) ) { + return ''; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Missing + return sanitize_text_field( wp_unslash( $_POST[ $field ] ) ); + + } + + /** + * Verifies the challenge response for the active provider. + * + * Returns true when spam protection is disabled or not configured, so + * existing sites without keys continue to work exactly as before. Returns + * a WP_Error when verification fails. + * + * @since 3.4.0 + * + * @param string $plugin_action The plugin action (used by reCAPTCHA's + * action check; ignored by Turnstile). + * @return bool|WP_Error + */ + public static function verify( $plugin_action ) { + + $provider = self::get_active_provider(); + + // No provider configured: nothing to check, allow the request through + // (matches the pre-3.4.0 behaviour when reCAPTCHA keys were absent). + if ( $provider === null ) { + return true; + } + + $response = self::get_response_from_post(); + + if ( $provider instanceof ConvertKit_Cloudflare_Turnstile ) { + return $provider->verify( $response ); + } + + // reCAPTCHA verify method requires a plugin action string. + return $provider->verify_recaptcha( $response, $plugin_action ); + + } + +} diff --git a/resources/frontend/js/convertkit.js b/resources/frontend/js/convertkit.js index 669791f57..bb82a5dd5 100644 --- a/resources/frontend/js/convertkit.js +++ b/resources/frontend/js/convertkit.js @@ -90,6 +90,40 @@ function convertKitRecaptchaFormSubmit(token) { // Scope the function to the window object as webpack will wrap everything in a closure, // resulting in the function not being available globally. window.convertKitRecaptchaFormSubmit = convertKitRecaptchaFormSubmit; + +/** + * Handles form submissions when Cloudflare Turnstile is enabled. + * + * Turnstile auto-populates a hidden `cf-turnstile-response` input inside the + * enclosing form once the challenge is solved, so we just need to submit the + * containing form when the callback fires. + * + * @param {string} token Turnstile response token. + */ +function convertKitTurnstileFormSubmit(token) { + // Find the Turnstile widget div with the data-callback attribute. + const widget = document.querySelector( + '.cf-turnstile[data-callback="convertKitTurnstileFormSubmit"]' + ); + + if (!widget) { + return; + } + + // Get the parent form of the widget. + const form = widget.closest('form'); + + if (!form) { + return; + } + + // Submit the form. + form.submit(); +} + +// Scope the function to the window object as webpack will wrap everything in a closure, +// resulting in the function not being available globally. +window.convertKitTurnstileFormSubmit = convertKitTurnstileFormSubmit; /* eslint-enable no-unused-vars */ /** diff --git a/tests/EndToEnd/general/plugin-screens/PluginSettingsGeneralCest.php b/tests/EndToEnd/general/plugin-screens/PluginSettingsGeneralCest.php index 1f949ca9c..2bb670a6f 100644 --- a/tests/EndToEnd/general/plugin-screens/PluginSettingsGeneralCest.php +++ b/tests/EndToEnd/general/plugin-screens/PluginSettingsGeneralCest.php @@ -624,6 +624,12 @@ public function testRecaptchaSettings(EndToEndTester $I) // Go to the Plugin's Settings Screen. $I->loadKitSettingsGeneralScreen($I); + // Provider defaults to reCAPTCHA, so its fields should be visible. + $I->seeInField('#spam_protection_provider', 'Google reCAPTCHA v3'); + $I->seeElement('#recaptcha_site_key'); + $I->seeElement('#recaptcha_secret_key'); + $I->seeElement('#recaptcha_minimum_score'); + // Fill in reCAPTCHA settings. $I->fillField('#recaptcha_site_key', $_ENV['CONVERTKIT_API_RECAPTCHA_SITE_KEY']); $I->fillField('#recaptcha_secret_key', $_ENV['CONVERTKIT_API_RECAPTCHA_SECRET_KEY']); @@ -657,6 +663,77 @@ public function testRecaptchaSettings(EndToEndTester $I) $I->dontSeeInField('#recaptcha_minimum_score', '0.01'); } + /** + * Test that the Spam Protection provider dropdown correctly toggles between + * reCAPTCHA and Cloudflare Turnstile field visibility, and that Turnstile + * settings can be saved and cleared without PHP errors. + * + * @since 3.4.0 + * + * @param EndToEndTester $I Tester. + */ + public function testTurnstileSettings(EndToEndTester $I) + { + // Setup Plugin. + $I->setupKitPlugin($I); + + // Go to the Plugin's Settings Screen. + $I->loadKitSettingsGeneralScreen($I); + + // Provider defaults to reCAPTCHA — Turnstile fields should be hidden. + $I->waitForElementNotVisible('#turnstile_site_key'); + $I->waitForElementNotVisible('#turnstile_secret_key'); + + // Switch provider to Cloudflare Turnstile. + $I->selectOption('#spam_protection_provider', 'Cloudflare Turnstile'); + + // Turnstile fields should now be visible; reCAPTCHA fields hidden. + $I->waitForElementVisible('#turnstile_site_key'); + $I->waitForElementVisible('#turnstile_secret_key'); + $I->waitForElementNotVisible('#recaptcha_site_key'); + $I->waitForElementNotVisible('#recaptcha_secret_key'); + $I->waitForElementNotVisible('#recaptcha_minimum_score'); + + // Fill in Turnstile settings using placeholder values (the settings screen + // does not perform a live handshake with Cloudflare; we only need to verify + // the values persist). + $I->fillField('#turnstile_site_key', '1x00000000000000000000AA'); + $I->fillField('#turnstile_secret_key', '1x0000000000000000000000000000000AA'); + + // Click the Save Changes button. + $I->click('Save Changes'); + + // Check that no PHP warnings or notices were output. + $I->checkNoWarningsAndNoticesOnScreen($I); + + // Check the Turnstile settings were saved and the provider selection persisted. + $I->seeInField('#spam_protection_provider', 'Cloudflare Turnstile'); + $I->seeInField('#turnstile_site_key', '1x00000000000000000000AA'); + $I->seeInField('#turnstile_secret_key', '1x0000000000000000000000000000000AA'); + + // Switch provider back to None. Turnstile fields should hide again. + $I->selectOption('#spam_protection_provider', 'None'); + $I->waitForElementNotVisible('#turnstile_site_key'); + $I->waitForElementNotVisible('#turnstile_secret_key'); + $I->waitForElementNotVisible('#recaptcha_site_key'); + + // Save. Provider selection should persist as `none`. + $I->click('Save Changes'); + $I->checkNoWarningsAndNoticesOnScreen($I); + $I->seeInField('#spam_protection_provider', 'None'); + + // Clear the Turnstile settings by reselecting Turnstile so the fields + // become editable again, then clearing them. + $I->selectOption('#spam_protection_provider', 'Cloudflare Turnstile'); + $I->waitForElementVisible('#turnstile_site_key'); + $I->clearField('#turnstile_site_key'); + $I->clearField('#turnstile_secret_key'); + $I->click('Save Changes'); + $I->checkNoWarningsAndNoticesOnScreen($I); + $I->seeInField('#turnstile_site_key', ''); + $I->seeInField('#turnstile_secret_key', ''); + } + /** * Test that no PHP errors or notices are displayed on the Plugin's Setting screen * when Debug settings are enabled and disabled. diff --git a/views/frontend/restrict-content/tag.php b/views/frontend/restrict-content/tag.php index 685e51f48..3ff4b4d13 100644 --- a/views/frontend/restrict-content/tag.php +++ b/views/frontend/restrict-content/tag.php @@ -21,8 +21,20 @@
settings->has_recaptcha_site_and_secret_keys() ) { + // Output submit button, plus a Turnstile widget div if Cloudflare Turnstile + // is the active spam protection provider. For reCAPTCHA v3 (invisible), + // the challenge is attached to the button itself. + $spam_provider = ConvertKit_Spam_Protection::get_active_provider(); + + if ( $spam_provider instanceof ConvertKit_Cloudflare_Turnstile ) { + ?> +
+ + Date: Wed, 29 Jul 2026 16:58:04 +0800 Subject: [PATCH 2/7] Refactor --- .env.example | 4 +- ...class-convertkit-admin-section-general.php | 77 ++++------- .../class-convertkit-block-form-builder.php | 35 ++--- .../class-convertkit-cloudflare-turnstile.php | 120 ++++++++++++------ ...ass-convertkit-output-restrict-content.php | 6 +- includes/class-convertkit-recaptcha.php | 51 +++++++- includes/class-convertkit-settings.php | 36 +++--- includes/class-convertkit-spam-protection.php | 102 +++++++-------- .../js/settings-conditional-display.js | 26 +++- views/frontend/restrict-content/tag.php | 30 ++--- 10 files changed, 280 insertions(+), 207 deletions(-) diff --git a/.env.example b/.env.example index c694caedb..ddce08124 100644 --- a/.env.example +++ b/.env.example @@ -28,5 +28,5 @@ CONVERTKIT_API_SIGNED_SUBSCRIBER_ID= CONVERTKIT_API_SIGNED_SUBSCRIBER_ID_NO_ACCESS= CONVERTKIT_API_RECAPTCHA_SITE_KEY= CONVERTKIT_API_RECAPTCHA_SECRET_KEY= -CONVERTKIT_TURNSTILE_SITE_KEY= -CONVERTKIT_TURNSTILE_SECRET_KEY= +CONVERTKIT_API_CLOUDFLARE_TURNSTILE_SITE_KEY= +CONVERTKIT_API_CLOUDFLARE_TURNSTILE_SECRET_KEY= diff --git a/admin/section/class-convertkit-admin-section-general.php b/admin/section/class-convertkit-admin-section-general.php index e1dd7beaf..95d4af4da 100644 --- a/admin/section/class-convertkit-admin-section-general.php +++ b/admin/section/class-convertkit-admin-section-general.php @@ -59,12 +59,12 @@ public function __construct() { // Define settings sections. $this->settings_sections = array( - 'general' => array( + 'general' => array( 'title' => $this->title, 'callback' => array( $this, 'print_section_info' ), 'wrap' => true, ), - 'site-wide' => array( + 'site-wide' => array( 'title' => __( 'Non-inline Forms', 'convertkit' ), 'callback' => array( $this, 'print_section_info_site_wide' ), 'wrap' => true, @@ -74,7 +74,7 @@ public function __construct() { 'callback' => array( $this, 'print_section_info_spam_protection' ), 'wrap' => true, ), - 'advanced' => array( + 'advanced' => array( 'title' => __( 'Advanced', 'convertkit' ), 'callback' => array( $this, 'print_section_info_advanced' ), 'wrap' => true, @@ -276,28 +276,6 @@ public function enqueue_scripts( $section ) { wp_enqueue_script( 'convertkit-admin-preview-output', CONVERTKIT_PLUGIN_URL . 'resources/backend/js/preview-output.js', array( 'jquery' ), CONVERTKIT_PLUGIN_VERSION, true ); wp_enqueue_script( 'convertkit-admin-settings-conditional-display', CONVERTKIT_PLUGIN_URL . 'resources/backend/js/settings-conditional-display.js', array( 'jquery' ), CONVERTKIT_PLUGIN_VERSION, true ); - // Inline: show/hide provider-specific rows based on the Spam Protection provider dropdown. - wp_add_inline_script( - 'convertkit-admin-settings-conditional-display', - 'document.addEventListener("DOMContentLoaded", function () { - var providerSelect = document.getElementById("spam_protection_provider"); - if ( ! providerSelect ) { return; } - function convertKitToggleSpamProtectionRows() { - var value = providerSelect.value; - var rows = document.querySelectorAll("tr.convertkit-spam-protection-recaptcha, tr.convertkit-spam-protection-turnstile"); - rows.forEach(function ( row ) { - if ( row.classList.contains("convertkit-spam-protection-" + value) ) { - row.style.display = ""; - } else { - row.style.display = "none"; - } - }); - } - providerSelect.addEventListener("change", convertKitToggleSpamProtectionRows); - convertKitToggleSpamProtectionRows(); - });' - ); - } /** @@ -495,28 +473,28 @@ public function register_fields() { // Cloudflare Turnstile. add_settings_field( - 'turnstile_site_key', - __( 'Turnstile: Site Key', 'convertkit' ), - array( $this, 'turnstile_site_key_callback' ), + 'cloudflare_turnstile_site_key', + __( 'Cloudflare Turnstile: Site Key', 'convertkit' ), + array( $this, 'cloudflare_turnstile_site_key_callback' ), $this->settings_key, $this->name . '-spam-protection', array( - 'label_for' => 'turnstile_site_key', - 'class' => 'convertkit-spam-protection-turnstile', + 'label_for' => 'cloudflare_turnstile_site_key', + 'class' => 'convertkit-spam-protection-cloudflare_turnstile', 'description' => array( __( 'Enter your Cloudflare Turnstile Site Key. When specified, this will be used to reduce spam signups.', 'convertkit' ), ), ) ); add_settings_field( - 'turnstile_secret_key', - __( 'Turnstile: Secret Key', 'convertkit' ), - array( $this, 'turnstile_secret_key_callback' ), + 'cloudflare_turnstile_secret_key', + __( 'Cloudflare Turnstile: Secret Key', 'convertkit' ), + array( $this, 'cloudflare_turnstile_secret_key_callback' ), $this->settings_key, $this->name . '-spam-protection', array( - 'label_for' => 'turnstile_secret_key', - 'class' => 'convertkit-spam-protection-turnstile', + 'label_for' => 'cloudflare_turnstile_secret_key', + 'class' => 'convertkit-spam-protection-cloudflare_turnstile', 'description' => array( __( 'Enter your Cloudflare Turnstile Secret Key. When specified, this will be used to reduce spam signups.', 'convertkit' ), ), @@ -619,7 +597,7 @@ public function print_section_info_site_wide() { /** * Prints help info for the spam protection section of the settings screen. * - * @since 3.4.0 + * @since 3.3.7 */ public function print_section_info_spam_protection() { ?> @@ -992,7 +970,7 @@ public function non_inline_form_limit_per_session_callback( $args ) { /** * Renders the dropdown for choosing the active spam protection provider. * - * @since 3.4.0 + * @since 3.3.7 * * @param array $args Setting field arguments (name,description). */ @@ -1002,11 +980,12 @@ public function spam_protection_provider_callback( $args ) { 'spam_protection_provider', esc_attr( $this->settings->spam_protection_provider() ), array( - 'none' => esc_html__( 'None', 'convertkit' ), - 'recaptcha' => esc_html__( 'Google reCAPTCHA v3', 'convertkit' ), - 'turnstile' => esc_html__( 'Cloudflare Turnstile', 'convertkit' ), + 'recaptcha' => esc_html__( 'Google reCAPTCHA v3', 'convertkit' ), + 'cloudflare_turnstile' => esc_html__( 'Cloudflare Turnstile', 'convertkit' ), ), - $args['description'] + $args['description'], + array( 'convertkit-conditional-display' ), + array( 'data-conditional-class-prefix' => 'convertkit-spam-protection-' ) ); } @@ -1080,15 +1059,15 @@ public function recaptcha_minimum_score_callback( $args ) { /** * Renders the input for the Cloudflare Turnstile Site Key setting. * - * @since 3.4.0 + * @since 3.3.7 * * @param array $args Setting field arguments (name,description). */ - public function turnstile_site_key_callback( $args ) { + public function cloudflare_turnstile_site_key_callback( $args ) { $this->output_text_field( - 'turnstile_site_key', - esc_attr( $this->settings->turnstile_site_key() ), + 'cloudflare_turnstile_site_key', + esc_attr( $this->settings->cloudflare_turnstile_site_key() ), $args['description'], array( 'widefat', @@ -1100,15 +1079,15 @@ public function turnstile_site_key_callback( $args ) { /** * Renders the input for the Cloudflare Turnstile Secret Key setting. * - * @since 3.4.0 + * @since 3.3.7 * * @param array $args Setting field arguments (name,description). */ - public function turnstile_secret_key_callback( $args ) { + public function cloudflare_turnstile_secret_key_callback( $args ) { $this->output_text_field( - 'turnstile_secret_key', - esc_attr( $this->settings->turnstile_secret_key() ), + 'cloudflare_turnstile_secret_key', + esc_attr( $this->settings->cloudflare_turnstile_secret_key() ), $args['description'], array( 'widefat', diff --git a/includes/blocks/class-convertkit-block-form-builder.php b/includes/blocks/class-convertkit-block-form-builder.php index 7c2dbcb94..9625088e4 100644 --- a/includes/blocks/class-convertkit-block-form-builder.php +++ b/includes/blocks/class-convertkit-block-form-builder.php @@ -78,7 +78,8 @@ public function maybe_subscribe() { } // Check spam protection (reCAPTCHA or Cloudflare Turnstile, depending on Plugin settings). - $spam_check = ConvertKit_Spam_Protection::verify( 'convertkit_form_builder' ); + $spam = new ConvertKit_Spam_Protection(); + $spam_check = $spam->verify( 'convertkit_form_builder' ); // Bail if spam protection failed. if ( is_wp_error( $spam_check ) ) { @@ -718,35 +719,21 @@ public function render_form_button( $block_content, $block ) { ); // Return the button as-is if no spam protection provider is active. - $provider = ConvertKit_Spam_Protection::get_active_provider(); + $spam = new ConvertKit_Spam_Protection(); + $provider = $spam->get_active_provider(); if ( $provider === null ) { return $block_content; } - // Enqueue the provider's client-side script. + // Enqueue the provider's client-side script and let it attach its + // widget / attributes to the button. Each provider handles its own + // DOM operation so this caller doesn't have to branch on the concrete + // class. $provider->enqueue_scripts(); - $settings = new ConvertKit_Settings(); - $parser = new ConvertKit_HTML_Parser( $block_content ); - $button = $parser->xpath->query( '//button' )->item( 0 ); - - if ( $provider instanceof ConvertKit_Cloudflare_Turnstile ) { - // Prepend a Turnstile widget div immediately before the button. - // interaction-only appearance keeps the widget invisible unless - // Cloudflare determines a challenge is required. - $widget = $parser->html->createElement( 'div' ); - $widget->setAttribute( 'class', 'cf-turnstile' ); - $widget->setAttribute( 'data-sitekey', esc_attr( $settings->turnstile_site_key() ) ); - $widget->setAttribute( 'data-appearance', 'interaction-only' ); - $widget->setAttribute( 'data-callback', 'convertKitTurnstileFormSubmit' ); - $button->parentNode->insertBefore( $widget, $button ); // @phpstan-ignore-line phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase - } else { - // reCAPTCHA v3 invisible: attach attributes to the submit button. - $button->setAttribute( 'data-sitekey', esc_attr( $settings->recaptcha_site_key() ) ); // @phpstan-ignore-line - $button->setAttribute( 'data-callback', 'convertKitRecaptchaFormSubmit' ); // @phpstan-ignore-line - $button->setAttribute( 'data-action', 'convertkit_form_builder' ); // @phpstan-ignore-line - $button->setAttribute( 'class', trim( $button->getAttribute( 'class' ) . ' g-recaptcha' ) ); // @phpstan-ignore-line - } + $parser = new ConvertKit_HTML_Parser( $block_content ); + $button = $parser->xpath->query( '//button' )->item( 0 ); + $provider->attach_to_form_button_dom( $parser, $button, 'convertkit_form_builder' ); // Return button HTML. return $parser->get_body_html(); diff --git a/includes/class-convertkit-cloudflare-turnstile.php b/includes/class-convertkit-cloudflare-turnstile.php index 1bfe8367b..85ffa05c3 100644 --- a/includes/class-convertkit-cloudflare-turnstile.php +++ b/includes/class-convertkit-cloudflare-turnstile.php @@ -9,14 +9,14 @@ /** * Handles Cloudflare Turnstile verification. * - * @since 3.4.0 + * @since 3.3.7 */ class ConvertKit_Cloudflare_Turnstile { /** * The endpoint used to validate Turnstile tokens server-side. * - * @since 3.4.0 + * @since 3.3.7 * * @var string */ @@ -25,7 +25,7 @@ class ConvertKit_Cloudflare_Turnstile { /** * The URL of the Turnstile client-side script. * - * @since 3.4.0 + * @since 3.3.7 * * @var string */ @@ -34,7 +34,7 @@ class ConvertKit_Cloudflare_Turnstile { /** * Holds the settings class. * - * @since 3.4.0 + * @since 3.3.7 * * @var bool|ConvertKit_Settings */ @@ -43,7 +43,7 @@ class ConvertKit_Cloudflare_Turnstile { /** * Constructor. * - * @since 3.4.0 + * @since 3.3.7 */ public function __construct() { @@ -52,15 +52,15 @@ public function __construct() { } /** - * Enqueues the Turnstile client-side script if Turnstile site and secret keys are set, - * and scripts are enabled. + * Enqueues the Cloudflare Turnstile client-side script if Cloudflare Turnstile + * site and secret keys are set and scripts are enabled. * - * @since 3.4.0 + * @since 3.3.7 */ public function enqueue_scripts() { - // Don't run if Turnstile or scripts are disabled. - if ( ! $this->settings->has_turnstile_site_and_secret_keys() || $this->settings->scripts_disabled() ) { + // Don't run if Cloudflare Turnstile or scripts are disabled. + if ( ! $this->settings->has_cloudflare_turnstile_site_and_secret_keys() || $this->settings->scripts_disabled() ) { return; } @@ -83,22 +83,21 @@ function ( $scripts ) { } /** - * Verifies a Turnstile response token against the Siteverify API, if Turnstile - * site and secret keys are set, and scripts are enabled. + * Verifies a Cloudflare Turnstile response token against the Siteverify API, + * if Cloudflare Turnstile site and secret keys are set, and scripts are enabled. * * Mirrors the request format documented at * https://developers.cloudflare.com/turnstile/get-started/server-side-validation/ * - * @since 3.4.0 + * @since 3.3.7 * - * @param string $turnstile_response The Turnstile response token from the client. - * @return bool|WP_Error True on success or when Turnstile is not - * configured / disabled; WP_Error on failure. + * @param string $cloudflare_turnstile_response Cloudflare Turnstile response token from the client. + * @return bool|WP_Error */ - public function verify( $turnstile_response ) { + public function verify( $cloudflare_turnstile_response ) { // Don't run if Turnstile or scripts are disabled. - if ( ! $this->settings->has_turnstile_site_and_secret_keys() || $this->settings->scripts_disabled() ) { + if ( ! $this->settings->has_cloudflare_turnstile_site_and_secret_keys() || $this->settings->scripts_disabled() ) { return true; } @@ -107,8 +106,8 @@ public function verify( $turnstile_response ) { self::SITEVERIFY_URL, array( 'body' => array( - 'secret' => $this->settings->turnstile_secret_key(), - 'response' => $turnstile_response, + 'secret' => $this->settings->cloudflare_turnstile_secret_key(), + 'response' => $cloudflare_turnstile_response, 'remoteip' => ( isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '' ), ), ) @@ -125,29 +124,76 @@ public function verify( $turnstile_response ) { // If the response body couldn't be decoded, treat that as a failure. if ( ! is_array( $body ) ) { return new WP_Error( - 'convertkit_turnstile_failed', + 'convertkit_cloudflare_turnstile_failed', __( 'Cloudflare Turnstile failure: invalid response from Siteverify.', 'convertkit' ) ); } - // Cloudflare returns { success: bool, "error-codes": [...] }. - if ( empty( $body['success'] ) ) { - $error_codes = ( isset( $body['error-codes'] ) && is_array( $body['error-codes'] ) ) - ? $body['error-codes'] - : array( 'unknown-error' ); - - return new WP_Error( - 'convertkit_turnstile_failed', - sprintf( - /* translators: Error codes */ - __( 'Cloudflare Turnstile failure: %s', 'convertkit' ), - implode( ', ', $error_codes ) - ) - ); + // If the token verified, return true. + if ( $body['success'] === true ) { + return true; } - // Token verified. - return true; + // Return an error. + return new WP_Error( + 'convertkit_cloudflare_turnstile_failed', + sprintf( + /* translators: Error codes */ + __( 'Cloudflare Turnstile failure: %s', 'convertkit' ), + implode( ', ', $body['error-codes'] ) + ) + ); + + } + + /** + * Inserts a Cloudflare Turnstile widget div immediately before the given + * submit button within an existing DOM tree. `data-appearance=interaction-only` + * keeps the widget invisible unless Cloudflare determines a challenge is + * required, and the `convertKitTurnstileFormSubmit` callback submits the + * enclosing form once the challenge is solved. + * + * @since 3.3.7 + * + * @param ConvertKit_HTML_Parser $parser Parser wrapping the DOM. + * @param DOMElement $button