api = new RECAPTCHA_API(); $this->token_verifier = new Token_Verifier( $this, $this->api ); $this->plugin_settings = new Settings\Plugin_Settings( $this, $this->token_verifier ); $this->field = new GF_Field_RECAPTCHA(); GF_Fields::register( $this->field ); add_filter( 'gform_settings_menu', array( $this, 'replace_core_recaptcha_menu_item' ) ); parent::pre_init(); } /** * Replaces the core recaptcha settings menu item with the addon settings menu item. * * @param array $settings_tabs Registered settings tabs. * * @since 1.0 * * @return array */ public function replace_core_recaptcha_menu_item( $settings_tabs ) { // Get tab names with the same index as is in the settings tabs. $tabs = array_combine( array_keys( $settings_tabs ), array_column( $settings_tabs, 'name' ) ); // Bail if for some reason this add-on is not registered as a settings tab. if ( ! in_array( $this->_slug, $tabs ) ) { return $settings_tabs; } $prepared_tabs = array_flip( $tabs ); $settings_tabs[ rgar( $prepared_tabs, 'recaptcha' ) ]['name'] = $this->_slug; unset( $settings_tabs[ rgar( $prepared_tabs, $this->_slug ) ] ); return $settings_tabs; } /** * Register initialization hooks. * * @since 1.0 */ public function init() { parent::init(); if ( ! $this->is_gravityforms_supported( $this->_min_gravityforms_version ) ) { return; } // Enqueue shared scripts that need to run everywhere, instead of just on forms pages. add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_recaptcha_script' ) ); // Add Recaptcha field to the form output. add_filter( 'gform_form_tag', array( $this, 'add_recaptcha_input' ), 50, 2 ); // Register a custom metabox for the entry details page. add_filter( 'gform_entry_detail_meta_boxes', array( $this, 'register_meta_box' ), 10, 3 ); add_filter( 'gform_entry_is_spam', array( $this, 'check_for_spam_entry' ), 10, 3 ); add_filter( 'gform_validation', array( $this, 'validate_submission' ) ); add_filter( 'gform_field_content', array( $this, 'update_captcha_field_settings_link' ), 10, 2 ); add_filter( 'gform_incomplete_submission_pre_save', array( $this, 'add_recaptcha_v3_input_to_draft' ), 10, 3 ); } /** * Register admin initialization hooks. * * @since 1.0 */ public function init_admin() { parent::init_admin(); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_recaptcha_script' ) ); } /** * Validate the secret key on the plugin settings screen. * * @since 1.0 */ public function init_ajax() { parent::init_ajax(); add_action( 'wp_ajax_verify_secret_key', array( $this->plugin_settings, 'verify_v3_keys' ) ); } /** * Register scripts. * * @since 1.0 * * @return array */ public function scripts() { $scripts = array( array( 'handle' => "{$this->asset_prefix}frontend", 'src' => $this->get_script_url( 'frontend' ), 'version' => $this->_version, 'deps' => array( 'jquery', "{$this->asset_prefix}recaptcha" ), 'in_footer' => true, 'enqueue' => array( array( $this, 'frontend_script_callback' ), ), ), ); // Prevent plugin settings from loading on the frontend. Remove this condition to see it in action. if ( is_admin() ) { if ( $this->requires_recaptcha_script() ) { $admin_deps = array( 'jquery', "{$this->asset_prefix}recaptcha" ); } else { $admin_deps = array( 'jquery' ); } $scripts[] = array( 'handle' => "{$this->asset_prefix}plugin_settings", 'src' => $this->get_script_url( 'plugin_settings' ), 'version' => $this->_version, 'deps' => $admin_deps, 'enqueue' => array( array( 'admin_page' => array( 'plugin_settings' ), 'tab' => $this->_slug, ), ), ); } return array_merge( parent::scripts(), $scripts ); } /** * Get the URL for a JavaScript file. * * @since 1.0 * * @param string $filename The name of the script to return. * * @return string */ private function get_script_url( $filename ) { $base_path = $this->get_base_path() . '/js'; $base_url = $this->get_base_url() . '/js'; // Production scripts. if ( is_readable( "{$base_path}/{$filename}.min.js" ) && ! ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ) { return "{$base_url}/{$filename}.min.js"; } // Uncompiled scripts. if ( is_readable( "{$base_path}/src/{$filename}.js" ) ) { return "{$base_url}/src/{$filename}.js"; } // Compiled dev scripts. return "{$base_url}/{$filename}.js"; } // # PLUGIN SETTINGS ----------------------------------------------------------------------------------------------- /** * Define plugin settings fields. * * @since 1.0 * * @return array */ public function plugin_settings_fields() { return $this->plugin_settings->get_fields(); } /** * Initialize the plugin settings. * * This method overrides the add-on framework because we need to retrieve the values for reCAPTCHA v2 from core * and populate them if they exist. Since the Plugin_Settings class houses all of the logic related to the plugin * settings screen, we need to pass the return value of this method's parent to delegate that responsibility. * * In a future release, once reCAPTCHA logic is migrated into this add-on, we * should be able to safely remove this override. * * @since 1.0 * * @return array */ public function get_plugin_settings() { return $this->plugin_settings->get_settings( parent::get_plugin_settings() ); } /** * Callback to update plugin settings on save. * * We override this method in order to save values for reCAPTCHA v2 with their original keys in the options table. * In a future release, we'll eventually migrate all previous reCAPTCHA logic into this add-on, at which time we * should be able to remove this method altogether. * * @since 1.0 * * @param array $settings The settings to update. */ public function update_plugin_settings( $settings ) { $this->plugin_settings->update_settings( $settings ); parent::update_plugin_settings( $settings ); } /** * The settings page icon. * * @since 1.0 * @return string */ public function get_menu_icon() { return 'gform-icon--recaptcha'; } /** * Add the recaptcha field to the end of the form. * * @since 1.0 * * @depecated 1.1 * * @param array $form The form array. * * @return array */ public function add_recaptcha_field( $form ) { return $form; } /** * Add the recaptcha input to the form. * * @since 1.1 * * @param string $form_tag The form tag. * @param array $form The form array. * * @return string */ public function add_recaptcha_input( $form_tag, $form ) { if ( empty( $form_tag ) || $this->is_disabled_by_form_setting( $form ) || ! $this->initialize_api() ) { return $form_tag; } return $form_tag . $this->field->get_field_input( $form ); } // # FORM SETTINGS /** * Register a form settings tab for reCAPTCHA v3. * * @since 1.0 * * @param array $form The form data. * * @return array */ public function form_settings_fields( $form ) { return array( array( 'title' => 'reCAPTCHA Settings', 'fields' => array( array( 'type' => 'checkbox', 'name' => 'disable-recaptchav3', 'choices' => array( array( 'name' => 'disable-recaptchav3', 'label' => __( 'Disable reCAPTCHA v3 for this form.', 'gravityformsrecaptcha' ), 'default_value' => 0, ), ), ), ), ), ); } /** * Updates the query string for the settings link displayed in the form editor preview of the Captcha field. * * @since 1.2 * * @param string $field_content The field markup. * @param \GF_Field $field The field being processed. * * @return string */ public function update_captcha_field_settings_link( $field_content, $field ) { if ( $field->type !== 'captcha' || ! $field->is_form_editor() ) { return $field_content; } return str_replace( array( '&subview=recaptcha', '?page=gf_settings' ), array( '', '?page=gf_settings&subview=gravityformsrecaptcha' ), $field_content ); } // # HELPER METHODS ------------------------------------------------------------------------------------------------ /** * Get the instance of the Token_Verifier class. * * @since 1.0 * * @return Token_Verifier */ public function get_token_verifier() { return $this->token_verifier; } /** * Get the instance of the Plugin_Settings class. * * @return Settings\Plugin_Settings */ public function get_plugin_settings_instance() { return $this->plugin_settings; } /** * Initialize the connection to the reCAPTCHA API. * * @since 1.0 * * @return bool */ private function initialize_api() { static $result; if ( is_bool( $result ) ) { return $result; } $result = false; $site_key = $this->plugin_settings->get_recaptcha_key( 'site_key_v3' ); $secret_key = $this->plugin_settings->get_recaptcha_key( 'secret_key_v3' ); if ( ! ( $site_key && $secret_key ) ) { $this->log_debug( __METHOD__ . '(): Missing v3 key configuration. Please check the add-on settings.' ); return false; } if ( '1' !== $this->get_plugin_setting( 'recaptcha_keys_status_v3' ) ) { $this->log_debug( __METHOD__ . '(): Could not initialize reCAPTCHA v3 because site and/or secret key is invalid.' ); return false; } $result = true; $this->log_debug( __METHOD__ . '(): API Initialized.' ); return true; } /** * Check to determine whether the reCAPTCHA script is needed on a page. * * The script is needed on every page of the front-end if we're able to initialize the API because we've already * verified that the v3 site and secret keys are valid. * * On the back-end, we only want to load this on the settings page, and it should be available regardless of the * status of the keys. * * @since 1.0 * * @return bool */ private function requires_recaptcha_script() { return is_admin() ? $this->is_plugin_settings( $this->_slug ) : $this->initialize_api(); } /** * Custom enqueuing of the external reCAPTCHA script. * * This script is enqueued via the normal WordPress process because, on the front-end, it's needed on every * single page of the site in order for reCAPTCHA to properly score the interactions leading up to the form * submission. * * @since 1.0 * @see GF_RECAPTCHA::init() */ public function enqueue_recaptcha_script() { if ( ! $this->requires_recaptcha_script() ) { return; } $script_url = add_query_arg( 'render', $this->plugin_settings->get_recaptcha_key( 'site_key_v3' ), 'https://www.google.com/recaptcha/api.js' ); wp_enqueue_script( "{$this->asset_prefix}recaptcha", $script_url, array( 'jquery' ), $this->_version, true ); wp_localize_script( "{$this->asset_prefix}recaptcha", "{$this->asset_prefix}recaptcha_strings", array( 'site_key' => $this->plugin_settings->get_recaptcha_key( 'site_key_v3' ), 'ajaxurl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( "{$this->_slug}_verify_token_nonce" ), ) ); if ( $this->get_plugin_setting( 'disable_badge_v3' ) !== '1' ) { return; } // Add inline JS to disable the badge. wp_add_inline_script( "{$this->asset_prefix}recaptcha", '(function($){grecaptcha.ready(function(){$(\'.grecaptcha-badge\').css(\'visibility\',\'hidden\');});})(jQuery);' ); } /** * Callback to determine whether to render the frontend script. * * @since 1.0 * * @param array $form The form array. * * @return bool */ public function frontend_script_callback( $form ) { return $form && ! is_admin(); } /** * Sets up additional data points for sorting on the entry. * * @since 1.0 * * @param array $entry_meta The entry metadata. * @param int $form_id The ID of the form. * * @return array */ public function get_entry_meta( $entry_meta, $form_id ) { $entry_meta[ "{$this->_slug}_score" ] = array( 'label' => __( 'reCAPTCHA Score', 'gravityformsrecaptcha' ), 'is_numeric' => true, 'update_entry_meta_callback' => array( $this, 'update_entry_meta' ), 'is_default_column' => true, 'filter' => array( 'operators' => array( 'is', '>', '<' ), ), ); return $entry_meta; } /** * Save the Recaptcha metadata values to the entry. * * @since 1.0 * * @see GF_RECAPTCHA::get_entry_meta() * * @param string $key The entry meta key. * @param array $entry The entry data. * @param array $form The form data. * * @return float|void */ public function update_entry_meta( $key, $entry, $form ) { if ( $key !== "{$this->_slug}_score" ) { return; } if ( $this->is_disabled_by_form_setting( $form ) ) { $this->log_debug( __METHOD__ . '(): reCAPTCHA v3 disabled on form ' . rgar( $form, 'id' ) ); return 'disabled'; } if ( ! $this->initialize_api() ) { return 'disconnected'; } return $this->token_verifier->get_score(); } /** * Registers a metabox on the entry details screen. * * @since 1.0 * * @param array $metaboxes Gravity Forms registered metaboxes. * @param array $entry The entry array. * @param array $form The form array. * * @return array */ public function register_meta_box( $metaboxes, $entry, $form ) { $score = $this->get_score_from_entry( $entry ); if ( ! $score ) { return $metaboxes; } $metaboxes[ $this->_slug ] = array( 'title' => esc_html__( 'reCAPTCHA', 'gravityformsrecaptcha' ), 'callback' => array( $this, 'add_recaptcha_meta_box' ), 'context' => 'side', ); return $metaboxes; } /** * Callback to output the entry details metabox. * * @since 1.0 * @see GF_RECAPTCHA::register_meta_box() * * @param array $data An array containing the form and entry data. */ public function add_recaptcha_meta_box( $data ) { $score = $this->get_score_from_entry( rgar( $data, 'entry' ) ); printf( '

%s: %s

%s

', esc_html__( 'Score', 'gravityformsrecaptcha' ), esc_html( $score ), esc_html( 'https://docs.gravityforms.com/captcha/' ), esc_html__( 'Click here to learn more about reCAPTCHA.', 'gravityformsrecaptcha' ) ); } /** * Callback to gform_entry_is_spam that determines whether to categorize this entry as such. * * @since 1.0 * * @see GF_RECAPTCHA::init(); * * @param bool $is_spam Whether the entry is spam. * @param array $form The form data. * @param array $entry The entry data. * * @return bool */ public function check_for_spam_entry( $is_spam, $form, $entry ) { if ( $is_spam ) { $this->log_debug( __METHOD__ . '(): Skipping, entry has already been identified as spam by another anti-spam solution.' ); return $is_spam; } $is_spam = $this->is_spam_submission( $form, $entry ); $this->log_debug( __METHOD__ . '(): Is submission considered spam? ' . ( $is_spam ? 'Yes.' : 'No.' ) ); return $is_spam; } /** * Determines if the submission is spam by comparing its score with the threshold. * * @since 1.4 * @since 1.5 Added the optional $entry param. * * @param array $form The form being processed. * @param array $entry The entry being processed. * * @return bool */ public function is_spam_submission( $form, $entry = array() ) { if ( $this->should_skip_validation( $form ) ) { $this->log_debug( __METHOD__ . '(): Score check skipped.' ); return false; } $score = empty( $entry ) ? $this->token_verifier->get_score() : $this->get_score_from_entry( $entry ); $threshold = $this->get_spam_score_threshold(); return (float) $score <= (float) $threshold; } /** * Get the Recaptcha score from the entry details. * * @since 1.0 * * @param array $entry The entry array. * * @return float|string */ private function get_score_from_entry( $entry ) { $score = rgar( $entry, "{$this->_slug}_score" ); if ( in_array( $score, $this->v3_disabled_states, true ) ) { return $score; } return $score ? (float) $score : $this->token_verifier->get_score(); } /** * The score that determines whether the entry is spam. * * Hard-coded for now, but this will eventually be an option within the add-on. * * @since 1.0 * * @return float */ private function get_spam_score_threshold() { static $value; if ( ! empty( $value ) ) { return $value; } $value = (float) $this->get_plugin_setting( 'score_threshold_v3' ); if ( empty( $value ) ) { $value = 0.5; } $this->log_debug( __METHOD__ . '(): ' . $value ); return $value; } /** * Determine whether a given form has disabled reCAPTCHA within its settings. * * @since 1.0 * * @param array $form The form data. * * @return bool */ private function is_disabled_by_form_setting( $form ) { return empty( $form['id'] ) || '1' === rgar( $this->get_form_settings( $form ), 'disable-recaptchav3' ); } /** * Validate the form submission. * * @since 1.0 * * @param array $submission_data The submitted form data. * * @return array */ public function validate_submission( $submission_data ) { $this->log_debug( __METHOD__ . '(): Validating form (#' . rgars( $submission_data, 'form/id' ) . ') submission.' ); if ( $this->should_skip_validation( rgar( $submission_data, 'form' ) ) ) { $this->log_debug( __METHOD__ . '(): Validation skipped.' ); return $submission_data; } $this->log_debug( __METHOD__ . '(): Validating reCAPTCHA v3.' ); return $this->field->validation_check( $submission_data ); } /** * Check If reCaptcha validation should be skipped. * * In some situations where the form validation could be triggered twice, for example while making a stripe payment element transaction * we want to skip the reCaptcha validation so it isn't triggered twice, as this will make it always fail. * * @since 1.4 * @since 1.5 Changed param to $form array. * * @param array $form The form being processed. * * @return bool */ public function should_skip_validation( $form ) { static $result = array(); $form_id = rgar( $form, 'id' ); if ( isset( $result[ $form_id ] ) ) { return $result[ $form_id ]; } $result[ $form_id ] = true; if ( $this->is_preview() ) { $this->log_debug( __METHOD__ . '(): Yes! Form preview page.' ); return true; } if ( ! $this->initialize_api() ) { $this->log_debug( __METHOD__ . '(): Yes! API not initialized.' ); return true; } if ( $this->is_disabled_by_form_setting( $form ) ) { $this->log_debug( __METHOD__ . '(): Yes! Disabled by form setting.' ); return true; } if ( defined( 'REST_REQUEST' ) && REST_REQUEST && ! isset( $_POST[ $this->field->get_input_name( $form_id ) ] ) ) { $this->log_debug( __METHOD__ . '(): Yes! REST request without input.' ); return true; } // For older versions of Stripe, skip the first validation attempt and only validate on the second attempt. Newer versions of Stripe will validate twice without a problem. if ( $this->is_stripe_validation() && version_compare( gf_stripe()->get_version(), '5.4.3', '<' ) ) { $this->log_debug( __METHOD__ . '(): Yes! Older Stripe validation.' ); return true; } $result[ $form_id ] = false; return false; } /** * Check if this is a stripe validation request. * * @since 1.4 * * @return bool Returns true if this is a stripe validation request. Returns false otherwise. */ public function is_stripe_validation() { return function_exists( 'gf_stripe' ) && rgpost( 'action' ) === 'gfstripe_validate_form'; } /** * Check if this is a preview request, taking into account Stripe's validation request. * * @since 1.4 * * @return bool Returns true if this is a preview request. Returns false otherwise. */ public function is_preview() { return parent::is_preview() || ( $this->is_stripe_validation() && rgget( 'preview' ) === '1' ); } /** * Add the recaptcha v3 input and value to the draft. * * @since 1.2 * * @param array $submission_json The json containing the submitted values and the partial entry created from the values. * @param string $resume_token The resume token. * @param array $form The form data. * * @return string The json string for the submission with the recaptcha v3 input and value added. */ public function add_recaptcha_v3_input_to_draft( $submission_json, $resume_token, $form ) { $submission = json_decode( $submission_json, true ); $input_name = $this->field->get_input_name( rgar( $form , 'id' ) ); $submission[ 'partial_entry' ][ $input_name ] = rgpost( $input_name ); return wp_json_encode( $submission ); } }