array( 'name' => __( 'Off', '2ranq-forms' ), 'note' => __( 'Only the hidden trap and the rate limit, which are always on.', '2ranq-forms' ), ), 'recaptcha' => array( 'name' => __( 'Google reCAPTCHA', '2ranq-forms' ), 'note' => __( 'Free, from Google. Works everywhere, and most people have seen it before.', '2ranq-forms' ), 'link' => 'https://www.google.com/recaptcha/admin/create', 'link_text' => __( 'Open the reCAPTCHA admin console', '2ranq-forms' ), 'steps' => array( __( 'Sign in with a Google account and press the plus to register a new site.', '2ranq-forms' ), __( 'Pick the same version you picked here, and type your domain under Domains.', '2ranq-forms' ), __( 'Press Submit. Google shows a Site Key and a Secret Key. Copy both into the two boxes below.', '2ranq-forms' ), ), 'verify' => 'https://www.google.com/recaptcha/api/siteverify', 'script' => 'https://www.google.com/recaptcha/api.js', ), 'hcaptcha' => array( 'name' => __( 'hCaptcha', '2ranq-forms' ), 'note' => __( 'Free, and it does not send your visitors to Google.', '2ranq-forms' ), 'link' => 'https://dashboard.hcaptcha.com/signup', 'link_text' => __( 'Open the hCaptcha dashboard', '2ranq-forms' ), 'steps' => array( __( 'Make a free account, then open Sites and press New Site.', '2ranq-forms' ), __( 'Add your domain and save. The Sitekey is shown in the list.', '2ranq-forms' ), __( 'The Secret Key is under Settings, in the section called Secret Key.', '2ranq-forms' ), ), 'verify' => 'https://api.hcaptcha.com/siteverify', 'script' => 'https://js.hcaptcha.com/1/api.js', ), 'turnstile' => array( 'name' => __( 'Cloudflare Turnstile', '2ranq-forms' ), 'note' => __( 'Free, quiet, and usually invisible to the visitor. The easiest of the three.', '2ranq-forms' ), // The dashboard address under the button the owner presses to go and // make their keys, the same as the two services above have. Nothing // is loaded from it, so it is not offloading either. 'link' => 'https://dash.cloudflare.com/?to=/:account/turnstile', // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent 'link_text' => __( 'Open Turnstile in Cloudflare', '2ranq-forms' ), 'steps' => array( __( 'Make a free Cloudflare account. Your site does not have to use Cloudflare for anything else.', '2ranq-forms' ), __( 'Open Turnstile, press Add widget, type your domain, and leave the mode on Managed.', '2ranq-forms' ), __( 'Copy the Site Key and the Secret Key into the two boxes below.', '2ranq-forms' ), ), // The two addresses below are the visitor check itself, not content // offloading: nothing of the site is served from them, they only // answer whether the visitor passed. The sniff cannot tell the two // apart because both live on cloudflare.com, so it is told here. 'verify' => 'https://challenges.cloudflare.com/turnstile/v0/siteverify', // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent 'script' => 'https://challenges.cloudflare.com/turnstile/v0/api.js', // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent ), ); } /** * The reCAPTCHA kinds. * * @return array */ public static function recaptcha_types() { return array( 'v2' => __( 'Version 2, the tick box', '2ranq-forms' ), 'v2_invisible' => __( 'Version 2, invisible', '2ranq-forms' ), 'v3' => __( 'Version 3, score based', '2ranq-forms' ), ); } /** * The settings, with their defaults. * * @return array */ public static function get() { $saved = get_option( self::OPTION, array() ); $saved = is_array( $saved ) ? $saved : array(); return array_merge( array( 'provider' => 'none', 'site_key' => '', 'secret' => '', 'rc_type' => 'v2', 'threshold' => '0.5', 'message' => __( 'The spam check did not pass. Please try again.', '2ranq-forms' ), ), $saved ); } /** * Saving the settings. * * @param array $raw The raw values. * * @return void */ public static function save( $raw ) { $stara = self::get(); $providers = self::providers(); $provider = isset( $raw['provider'] ) ? sanitize_key( $raw['provider'] ) : 'none'; $provider = isset( $providers[ $provider ] ) ? $provider : 'none'; $rc_type = isset( $raw['rc_type'] ) ? sanitize_key( $raw['rc_type'] ) : 'v2'; $rc_type = array_key_exists( $rc_type, self::recaptcha_types() ) ? $rc_type : 'v2'; $prag = isset( $raw['threshold'] ) ? (float) $raw['threshold'] : 0.5; $prag = min( 1, max( 0, $prag ) ); // The secret key is not cleared when the field arrives empty. The screen does // not print it back, so every save would otherwise wipe a working key. $tajna = isset( $raw['secret'] ) ? trim( sanitize_text_field( $raw['secret'] ) ) : ''; update_option( self::OPTION, array( 'provider' => $provider, 'site_key' => isset( $raw['site_key'] ) ? trim( sanitize_text_field( $raw['site_key'] ) ) : '', 'secret' => '' === $tajna ? $stara['secret'] : $tajna, 'rc_type' => $rc_type, 'threshold' => (string) $prag, 'message' => isset( $raw['message'] ) ? sanitize_text_field( $raw['message'] ) : $stara['message'], ), false ); } /** * Whether the protection is ready to use. * * @return bool */ public static function ready() { $s = self::get(); $providers = self::providers(); // The service also has to EXIST, not merely to be something other than // "none". A service name that has since left the plugin can stay in the // database, and the check would then call an address that is not there. if ( ! isset( $providers[ $s['provider'] ] ) || 'none' === $s['provider'] ) { return false; } return '' !== $s['site_key'] && '' !== $s['secret']; } /** * Whether this form asks for the check. * * @param int $form_id The form id. * * @return bool */ public static function enabled_for( $form_id ) { if ( ! self::ready() ) { return false; } $settings = Ranq_Forms_Form_Settings::get( $form_id ); return ! empty( $settings['spam_check'] ); } /** * Prints the field inside the form. * * @param int $form_id The form id. * * @return string */ public static function field( $form_id ) { if ( ! self::enabled_for( $form_id ) ) { return ''; } $s = self::get(); self::enqueue_script(); if ( 'recaptcha' === $s['provider'] && 'v3' === $s['rc_type'] ) { // Version three has nothing to show. The answer arrives by script, in a hidden field. return ''; } $klasa = 'hcaptcha' === $s['provider'] ? 'h-captcha' : ( 'turnstile' === $s['provider'] ? 'cf-turnstile' : 'g-recaptcha' ); $extra = ''; if ( 'recaptcha' === $s['provider'] && 'v2_invisible' === $s['rc_type'] ) { $extra = ' data-size="invisible"'; } return '
'; } /** * The service script. * * @return void */ private static function enqueue_script() { if ( self::$enqueued ) { return; } $s = self::get(); $providers = self::providers(); if ( ! isset( $providers[ $s['provider'] ]['script'] ) ) { return; } $src = $providers[ $s['provider'] ]['script']; if ( 'recaptcha' === $s['provider'] && 'v3' === $s['rc_type'] ) { $src = add_query_arg( 'render', rawurlencode( $s['site_key'] ), $src ); } wp_enqueue_script( 'ranq-forms-spam', $src, array(), null, true ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- the service address, the version is decided by the service. if ( 'recaptcha' === $s['provider'] && 'v3' === $s['rc_type'] ) { wp_add_inline_script( 'ranq-forms-spam', 'document.addEventListener( "submit", function ( e ) { var polje = e.target.querySelector( "[data-ranq-spam=v3]" ); if ( ! polje || polje.value || ! window.grecaptcha ) { return; } e.preventDefault(); window.grecaptcha.ready( function () { window.grecaptcha.execute( polje.dataset.ranqSitekey, { action: "submit" } ).then( function ( t ) { polje.value = t; e.target.submit(); } ); } ); }, true );' ); } self::$enqueued = true; } /** * The check on the server. * * @param int $form_id The form id. * * @return string Empty when everything is fine, otherwise a message. */ public static function check( $form_id ) { if ( ! self::enabled_for( $form_id ) ) { return ''; } $s = self::get(); $providers = self::providers(); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- the form nonce is already checked in the caller. $odgovor = isset( $_POST['g-recaptcha-response'] ) ? sanitize_text_field( wp_unslash( $_POST['g-recaptcha-response'] ) ) : ''; if ( '' === $odgovor ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- the same. $odgovor = isset( $_POST['h-captcha-response'] ) ? sanitize_text_field( wp_unslash( $_POST['h-captcha-response'] ) ) : ''; } if ( '' === $odgovor ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- the same. $odgovor = isset( $_POST['cf-turnstile-response'] ) ? sanitize_text_field( wp_unslash( $_POST['cf-turnstile-response'] ) ) : ''; } if ( '' === $odgovor ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- the same. $odgovor = isset( $_POST['ranq_spam_token'] ) ? sanitize_text_field( wp_unslash( $_POST['ranq_spam_token'] ) ) : ''; } if ( '' === $odgovor ) { return $s['message']; } $r = wp_remote_post( $providers[ $s['provider'] ]['verify'], array( 'timeout' => 10, 'body' => array( 'secret' => $s['secret'], 'response' => $odgovor, 'remoteip' => self::ip(), ), ) ); if ( is_wp_error( $r ) ) { // The service is unavailable. The form is LET THROUGH, because a fault on // their side would otherwise stop every enquiry on the site. The bot trap // and the rate limit still work. return ''; } $telo = json_decode( wp_remote_retrieve_body( $r ), true ); if ( ! is_array( $telo ) || empty( $telo['success'] ) ) { return $s['message']; } // Version three gives a score, not a yes or a no. if ( isset( $telo['score'] ) && (float) $telo['score'] < (float) $s['threshold'] ) { return $s['message']; } return ''; } /** * The visitor's address, for the service. * * @return string */ private static function ip() { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; return filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : ''; } }