diff --git a/.env.example b/.env.example index d2b260095..6f185284e 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,8 @@ CONVERTKIT_API_KEY= CONVERTKIT_API_SECRET= CONVERTKIT_API_SIGNED_SUBSCRIBER_ID= CONVERTKIT_API_SIGNED_SUBSCRIBER_ID_NO_ACCESS= +CONVERTKIT_API_RECAPTCHA_SITE_KEY= +CONVERTKIT_API_RECAPTCHA_SECRET_KEY= CONVERTKIT_API_FORM_NAME="Page Form [inline]" CONVERTKIT_API_FORM_ID="2765139" CONVERTKIT_API_FORM_FORMAT_MODAL_NAME="Modal Form [modal]" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9732e7d84..9f8f97906 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -46,6 +46,8 @@ jobs: KIT_OAUTH_REDIRECT_URI: ${{ secrets.KIT_OAUTH_REDIRECT_URI }} CONVERTKIT_API_SIGNED_SUBSCRIBER_ID: ${{ secrets.CONVERTKIT_API_SIGNED_SUBSCRIBER_ID }} # ConvertKit API Signed Subscriber ID, stored in the repository's Settings > Secrets CONVERTKIT_API_SIGNED_SUBSCRIBER_ID_NO_ACCESS: ${{ secrets.CONVERTKIT_API_SIGNED_SUBSCRIBER_ID_NO_ACCESS }} # ConvertKit API Signed Subscriber ID with no access to Products, stored in the repository's Settings > Secrets + CONVERTKIT_API_RECAPTCHA_SITE_KEY: ${{ secrets.CONVERTKIT_API_RECAPTCHA_SITE_KEY }} # Google reCAPTCHA v3 Site Key, stored in the repository's Settings > Secrets + CONVERTKIT_API_RECAPTCHA_SECRET_KEY: ${{ secrets.CONVERTKIT_API_RECAPTCHA_SECRET_KEY }} # Google reCAPTCHA v3 Secret Key, stored in the repository's Settings > Secrets # Defines the WordPress and PHP Versions matrix to run tests on # WooCommerce 5.9.0 requires WordPress 5.6 or greater, so we do not test on earlier versions @@ -218,6 +220,9 @@ jobs: KIT_OAUTH_REDIRECT_URI=${{ env.KIT_OAUTH_REDIRECT_URI }} CONVERTKIT_API_SIGNED_SUBSCRIBER_ID=${{ env.CONVERTKIT_API_SIGNED_SUBSCRIBER_ID }} CONVERTKIT_API_SIGNED_SUBSCRIBER_ID_NO_ACCESS=${{ env.CONVERTKIT_API_SIGNED_SUBSCRIBER_ID_NO_ACCESS }} + CONVERTKIT_API_RECAPTCHA_SITE_KEY=${{ env.CONVERTKIT_API_RECAPTCHA_SITE_KEY }} + CONVERTKIT_API_RECAPTCHA_SECRET_KEY=${{ env.CONVERTKIT_API_RECAPTCHA_SECRET_KEY }} + write-mode: append # Installs wp-browser, Codeception, PHP CodeSniffer and anything else needed to run tests. diff --git a/admin/class-convertkit-admin-settings-restrict-content.php b/admin/class-convertkit-admin-settings-restrict-content.php index fbaee9b03..36ca211fe 100644 --- a/admin/class-convertkit-admin-settings-restrict-content.php +++ b/admin/class-convertkit-admin-settings-restrict-content.php @@ -80,6 +80,53 @@ public function register_fields() { ) ); + // reCAPTCHA. + add_settings_field( + 'recaptcha_site_key', + __( 'reCAPTCHA: Site Key', 'convertkit' ), + array( $this, 'text_callback' ), + $this->settings_key, + $this->name, + array( + 'name' => 'recaptcha_site_key', + 'label_for' => 'recaptcha_site_key', + 'description' => array( + __( 'Enter your Google reCAPTCHA v3 Site Key. When specified, this will be used in Member Content by Tag functionality to reduce spam signups.', 'convertkit' ), + ), + ) + ); + add_settings_field( + 'recaptcha_secret_key', + __( 'reCAPTCHA: Secret Key', 'convertkit' ), + array( $this, 'text_callback' ), + $this->settings_key, + $this->name, + array( + 'name' => 'recaptcha_secret_key', + 'label_for' => 'recaptcha_secret_key', + 'description' => array( + __( 'Enter your Google reCAPTCHA v3 Secret Key. When specified, this will be used in Member Content by Tag functionality to reduce spam signups.', 'convertkit' ), + ), + ) + ); + add_settings_field( + 'recaptcha_minimum_score', + __( 'reCAPTCHA: Minimum Score', 'convertkit' ), + array( $this, 'number_callback' ), + $this->settings_key, + $this->name, + array( + 'name' => 'recaptcha_minimum_score', + 'label_for' => 'recaptcha_minimum_score', + 'min' => 0, + 'max' => 1, + 'step' => 0.01, + 'description' => array( + __( 'Enter the minimum threshold for a subscriber to pass Google reCAPTCHA. A higher number will reduce spam signups (1.0 is very likely a good interaction, 0.0 is very likely a bot).', 'convertkit' ), + ), + ) + ); + // Restrict by Product. add_settings_field( 'subscribe_heading', @@ -345,6 +392,29 @@ public function text_callback( $args ) { } + /** + * Renders the input for the decimal setting. + * + * @since 2.6.8 + * + * @param array $args Setting field arguments (name,description). + */ + public function number_callback( $args ) { + + echo $this->get_number_field( // phpcs:ignore WordPress.Security.EscapeOutput + $args['name'], + esc_attr( $this->settings->get_by_key( $args['name'] ) ), + $args['min'], // phpcs:ignore WordPress.Security.EscapeOutput + $args['max'], // phpcs:ignore WordPress.Security.EscapeOutput + $args['step'], // phpcs:ignore WordPress.Security.EscapeOutput + $args['description'], // phpcs:ignore WordPress.Security.EscapeOutput + array( + 'widefat', + ) + ); + + } + /** * Renders the input for the textarea setting. * diff --git a/admin/section/class-convertkit-settings-base.php b/admin/section/class-convertkit-settings-base.php index bb1923d56..e3adc8d00 100644 --- a/admin/section/class-convertkit-settings-base.php +++ b/admin/section/class-convertkit-settings-base.php @@ -443,9 +443,9 @@ public function get_text_field( $name, $value = '', $description = false, $css_c * * @param string $name Name. * @param string $value Value. - * @param int $min `min` attribute value. - * @param int $max `max` attribute value. - * @param int $step `step` attribute value. + * @param int|float $min `min` attribute value. + * @param int|float $max `max` attribute value. + * @param int|float $step `step` attribute value. * @param bool|string|array $description Description (false|string|array). * @param bool|array $css_classes CSS Classes (false|array). * @return string HTML Field diff --git a/includes/class-convertkit-output-restrict-content.php b/includes/class-convertkit-output-restrict-content.php index 30ac4f108..86defd3d4 100644 --- a/includes/class-convertkit-output-restrict-content.php +++ b/includes/class-convertkit-output-restrict-content.php @@ -207,6 +207,61 @@ public function maybe_run_subscriber_authentication() { break; case 'tag': + // If Google reCAPTCHA is enabled, check if the submission is spam. + if ( $this->restrict_content_settings->has_recaptcha_site_and_secret_keys() ) { + $response = wp_remote_post( + 'https://www.google.com/recaptcha/api/siteverify', + array( + 'body' => array( + 'secret' => $this->restrict_content_settings->get_recaptcha_secret_key(), + 'response' => $_POST['g-recaptcha-response'], + 'remoteip' => $_SERVER['REMOTE_ADDR'], + ), + ) + ); + + // Bail if an error occured. + if ( is_wp_error( $response ) ) { + $this->error = $response; + return; + } + + // Inspect response. + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + // If the request wasn't successful, throw an error. + if ( ! $body['success'] ) { + $this->error = new WP_Error( + 'convertkit_output_restrict_content_maybe_run_subscriber_authentication_error', + sprintf( + /* translators: Error codes */ + __( 'Google reCAPTCHA failure: %s', 'convertkit' ), + implode( ', ', $body['error-codes'] ) + ) + ); + return; + } + + // If the action doesn't match the Plugin action, this might not be a reCAPTCHA request + // for this Plugin. + if ( $body['action'] !== 'convertkit_restrict_content_tag' ) { + // Just silently return. + return; + } + + // If the score is less than 0.5 (on a scale of 0.0 to 1.0, with 0.0 being a bot, 1.0 being very good), + // it's likely a spam submission. + if ( $body['score'] < $this->restrict_content_settings->get_recaptcha_minimum_score() ) { + $this->error = new WP_Error( + 'convertkit_output_restrict_content_maybe_run_subscriber_authentication_error', + __( 'Google reCAPTCHA failed', 'convertkit' ) + ); + return; + } + + // If here, the submission looks genuine. Continue the request. + } + // Tag the subscriber. $result = $this->api->tag_subscribe( $this->resource_id, $email ); @@ -1051,6 +1106,22 @@ function () { return trim( ob_get_clean() ); case 'tag': + // Enqueue Google reCAPTCHA JS if site and secret keys specified. + if ( $this->restrict_content_settings->has_recaptcha_site_and_secret_keys() ) { + add_filter( + 'convertkit_output_scripts_footer', + function ( $scripts ) { + + $scripts[] = array( + 'src' => 'https://www.google.com/recaptcha/api.js?', + ); + + return $scripts; + + } + ); + } + // Output. ob_start(); include CONVERTKIT_PLUGIN_PATH . '/views/frontend/restrict-content/tag.php'; diff --git a/includes/class-convertkit-settings-restrict-content.php b/includes/class-convertkit-settings-restrict-content.php index ffa1bf836..9d1071f69 100644 --- a/includes/class-convertkit-settings-restrict-content.php +++ b/includes/class-convertkit-settings-restrict-content.php @@ -77,6 +77,85 @@ public function permit_crawlers() { } + /** + * Returns the reCAPTCHA Site Key Plugin setting. + * + * @since 2.6.8 + * + * @return string + */ + public function get_recaptcha_site_key() { + + return $this->settings['recaptcha_site_key']; + + } + + /** + * Returns whether the reCAPTCHA Site Key has been set in the Plugin settings. + * + * @since 2.6.8 + * + * @return bool + */ + public function has_recaptcha_site_key() { + + return ! empty( $this->get_recaptcha_site_key() ); + + } + + /** + * Returns the reCAPTCHA Secret Key Plugin setting. + * + * @since 2.6.8 + * + * @return string + */ + public function get_recaptcha_secret_key() { + + return $this->settings['recaptcha_secret_key']; + + } + + /** + * Returns whether the reCAPTCHA Secret Key has been set in the Plugin settings. + * + * @since 2.6.8 + * + * @return bool + */ + public function has_recaptcha_secret_key() { + + return ! empty( $this->get_recaptcha_secret_key() ); + + } + + /** + * Returns whether the reCAPTCH Site Key and Secret Key are defined + * in the Plugin settings. + * + * @since 2.6.8 + * + * @return bool + */ + public function has_recaptcha_site_and_secret_keys() { + + return $this->get_recaptcha_site_key() && $this->has_recaptcha_secret_key(); + + } + + /** + * Returns the reCAPTCHA minimum score Plugin setting. + * + * @since 2.6.8 + * + * @return float + */ + public function get_recaptcha_minimum_score() { + + return (float) $this->settings['recaptcha_minimum_score']; + + } + /** * Returns Restrict Content settings value for the given key. * @@ -114,25 +193,30 @@ public function get_defaults() { $defaults = array( // Permit Crawlers. - 'permit_crawlers' => '', + 'permit_crawlers' => '', + + // Google reCAPTCHA. + 'recaptcha_site_key' => '', + 'recaptcha_secret_key' => '', + 'recaptcha_minimum_score' => '0.5', // Restrict by Product. - 'subscribe_heading' => __( 'Read this post with a premium subscription', 'convertkit' ), - 'subscribe_text' => __( 'This post is only available to premium subscribers. Join today to get access to all posts.', 'convertkit' ), + 'subscribe_heading' => __( 'Read this post with a premium subscription', 'convertkit' ), + 'subscribe_text' => __( 'This post is only available to premium subscribers. Join today to get access to all posts.', 'convertkit' ), // Restrict by Tag. - 'subscribe_heading_tag' => __( 'Subscribe to keep reading', 'convertkit' ), - 'subscribe_text_tag' => __( 'This post is free to read but only available to subscribers. Join today to get access to all posts.', 'convertkit' ), + 'subscribe_heading_tag' => __( 'Subscribe to keep reading', 'convertkit' ), + 'subscribe_text_tag' => __( 'This post is free to read but only available to subscribers. Join today to get access to all posts.', 'convertkit' ), // All. - 'subscribe_button_label' => __( 'Subscribe', 'convertkit' ), - 'email_text' => __( 'Already subscribed?', 'convertkit' ), - 'email_button_label' => __( 'Log in', 'convertkit' ), - 'email_heading' => __( 'Log in to read this post', 'convertkit' ), - 'email_description_text' => __( 'We\'ll email you a magic code to log you in without a password.', 'convertkit' ), - 'email_check_heading' => __( 'We just emailed you a log in code', 'convertkit' ), - 'email_check_text' => __( 'Enter the code below to finish logging in', 'convertkit' ), - 'no_access_text' => __( 'Your account does not have access to this content. Please use the button above to purchase, or enter the email address you used to purchase the product.', 'convertkit' ), + 'subscribe_button_label' => __( 'Subscribe', 'convertkit' ), + 'email_text' => __( 'Already subscribed?', 'convertkit' ), + 'email_button_label' => __( 'Log in', 'convertkit' ), + 'email_heading' => __( 'Log in to read this post', 'convertkit' ), + 'email_description_text' => __( 'We\'ll email you a magic code to log you in without a password.', 'convertkit' ), + 'email_check_heading' => __( 'We just emailed you a log in code', 'convertkit' ), + 'email_check_text' => __( 'Enter the code below to finish logging in', 'convertkit' ), + 'no_access_text' => __( 'Your account does not have access to this content. Please use the button above to purchase, or enter the email address you used to purchase the product.', 'convertkit' ), ); /** diff --git a/resources/frontend/js/restrict-content.js b/resources/frontend/js/restrict-content.js index 98019aaab..d9ab7dceb 100644 --- a/resources/frontend/js/restrict-content.js +++ b/resources/frontend/js/restrict-content.js @@ -58,6 +58,13 @@ document.addEventListener( } ); +function convertKitRestrictContentTagFormSubmit( token ) { + + console.log( token ); + document.getElementById( 'convertkit-restrict-content-form' ).submit(); + +} + /** * Handles Restrict Content form submission. * diff --git a/tests/_support/Helper/Acceptance/ConvertKitRestrictContent.php b/tests/_support/Helper/Acceptance/ConvertKitRestrictContent.php index 3221cf19f..ca9481254 100644 --- a/tests/_support/Helper/Acceptance/ConvertKitRestrictContent.php +++ b/tests/_support/Helper/Acceptance/ConvertKitRestrictContent.php @@ -54,24 +54,27 @@ public function getRestrictedContentDefaultSettings() { return array( // Permit Crawlers. - 'permit_crawlers' => '', + 'permit_crawlers' => '', + 'recaptcha_site_key' => '', + 'recaptcha_secret_key' => '', + 'recaptcha_minimum_score' => '0.5', // Restrict by Product. - 'subscribe_heading' => 'Read this post with a premium subscription', - 'subscribe_text' => 'This post is only available to premium subscribers. Join today to get access to all posts.', + 'subscribe_heading' => 'Read this post with a premium subscription', + 'subscribe_text' => 'This post is only available to premium subscribers. Join today to get access to all posts.', // Restrict by Tag. - 'subscribe_heading_tag' => 'Subscribe to keep reading', - 'subscribe_text_tag' => 'This post is free to read but only available to subscribers. Join today to get access to all posts.', + 'subscribe_heading_tag' => 'Subscribe to keep reading', + 'subscribe_text_tag' => 'This post is free to read but only available to subscribers. Join today to get access to all posts.', // All. - 'subscribe_button_label' => 'Subscribe', - 'email_text' => 'Already subscribed?', - 'email_button_label' => 'Log in', - 'email_description_text' => 'We\'ll email you a magic code to log you in without a password.', - 'email_check_heading' => 'We just emailed you a log in code', - 'email_check_text' => 'Enter the code below to finish logging in', - 'no_access_text' => 'Your account does not have access to this content. Please use the button above to purchase, or enter the email address you used to purchase the product.', + 'subscribe_button_label' => 'Subscribe', + 'email_text' => 'Already subscribed?', + 'email_button_label' => 'Log in', + 'email_description_text' => 'We\'ll email you a magic code to log you in without a password.', + 'email_check_heading' => 'We just emailed you a log in code', + 'email_check_text' => 'Enter the code below to finish logging in', + 'no_access_text' => 'Your account does not have access to this content. Please use the button above to purchase, or enter the email address you used to purchase the product.', ); } @@ -95,6 +98,14 @@ public function checkRestrictContentSettings($I, $settings) } break; + case 'recaptcha_minimum_score': + if ( $value ) { + $I->seeInField('_wp_convertkit_settings_restrict_content[' . $key . ']', $value); + } else { + $I->seeInField('_wp_convertkit_settings_restrict_content[' . $key . ']', '0.5'); + } + break; + default: $I->seeInField('_wp_convertkit_settings_restrict_content[' . $key . ']', $value); break; @@ -341,8 +352,9 @@ public function testRestrictedContentModalByProductOnFrontend($I, $urlOrPageID, * @type string $member_content Content that should only be available to authenticated subscribers. * @type array $text_items Expected text for subscribe text, subscribe button label, email text etc. If not defined, uses expected defaults. * } + * @param bool $recaptchaEnabled Whether the reCAPTCHA settings are enabled in the Plugin settings. */ - public function testRestrictedContentByTagOnFrontend($I, $urlOrPageID, $emailAddress, $options = false) + public function testRestrictedContentByTagOnFrontend($I, $urlOrPageID, $emailAddress, $options = false, $recaptchaEnabled = false) { // Merge options with defaults. $options = $this->_getRestrictedContentOptionsWithDefaultsMerged($options); @@ -372,14 +384,16 @@ public function testRestrictedContentByTagOnFrontend($I, $urlOrPageID, $emailAdd $I->seeElementInDOM('#convertkit-restrict-content'); $I->seeInSource('