<?php
namespace WPForms\Frontend;
/**
* Captcha class.
*
* @since 1.8.1
*/
class Captcha {
/**
* Initialize class.
*
* @since 1.8.1
*/
public function init() {
$this->hooks();
}
/**
* Register hooks.
*
* @since 1.8.1
*/
private function hooks() {
// Filters.
add_filter( 'script_loader_tag', [ $this, 'set_defer_attribute' ], 10, 3 );
// Actions.
add_action( 'wpforms_frontend_output', [ $this, 'recaptcha' ], 20, 5 );
add_action( 'wp_enqueue_scripts', [ $this, 'recaptcha_noconflict' ], 9999 );
add_action( 'wp_footer', [ $this, 'recaptcha_noconflict' ], 19 );
add_action( 'wpforms_wp_footer', [ $this, 'assets_recaptcha' ] );
}
/**
* CAPTCHA output if configured.
*
* @since 1.8.1
*
* @param array $form_data Form data and settings.
* @param null $deprecated Deprecated in v1.3.7, previously was $form object.
* @param bool $title Whether to display form title.
* @param bool $description Whether to display form description.
* @param array $errors List of all errors filled in WPForms_Process::process().
*
* @noinspection HtmlUnknownAttribute
* @noinspection PhpUnusedParameterInspection
*/
public function recaptcha( $form_data, $deprecated, $title, $description, $errors ) {
// Check that CAPTCHA is configured in the settings.
$captcha_settings = $this->get_form_captcha_settings( $form_data );
if ( ! $captcha_settings ) {
return;
}
$frontend = wpforms()->obj( 'frontend' );
$container_classes = [ 'wpforms-recaptcha-container', 'wpforms-is-' . $captcha_settings['provider'] ];
if ( $captcha_settings['provider'] === 'recaptcha' ) {
$container_classes[] = 'wpforms-is-recaptcha-type-' . $captcha_settings['recaptcha_type'];
}
printf(
'<div class="%1$s" %2$s>',
wpforms_sanitize_classes( $container_classes, true ),
$frontend->pages ? 'style="display:none;"' : ''
);
$this->print_recaptcha_fields( $captcha_settings, $form_data );
if ( ! empty( $errors['recaptcha'] ) ) {
$frontend->form_error( 'recaptcha', $errors['recaptcha'] );
}
echo '</div>';
}
/**
* Get recaptcha data.
*
* @since 1.8.6
*
* @param array $captcha_settings Captcha settings.
* @param array $form_data Form data and settings.
*
* @return array
*/
private function get_recaptcha_data( array $captcha_settings, array $form_data ): array {
/**
* Filters captcha sitekey.
*
* @since 1.7.1
*
* @param array $sitekey Sitekey.
* @param array $form_data Form data and settings.
*/
$data = apply_filters( // phpcs:ignore WPForms.PHP.ValidateHooks.InvalidHookName
'wpforms_frontend_recaptcha',
[ 'sitekey' => $captcha_settings['site_key'] ],
$form_data
);
$is_recaptcha = $captcha_settings['provider'] === 'recaptcha';
$is_turnstile = $captcha_settings['provider'] === 'turnstile';
if ( $is_recaptcha && $captcha_settings['recaptcha_type'] === 'invisible' ) {
$data['size'] = 'invisible';
}
if ( ! $is_turnstile ) {
return $data;
}
/**
* Filter Turnstile action value.
*
* @since 1.8.1
*
* @param string $action Action value. Can only contain up to 32 alphanumeric characters including _ and -.
* @param array $form_data Form data and settings.
*/
$data['action'] = apply_filters( // phpcs:ignore WPForms.PHP.ValidateHooks.InvalidHookName
'wpforms_frontend_recaptcha_turnstile_action',
sprintf(
'FormID-%d',
$form_data['id']
),
$form_data
);
return $data;
}
/**
* Print recaptcha fields.
*
* @since 1.8.6
*
* @param array $captcha_settings Captcha settings.
* @param array $form_data Form data and settings.
*/
private function print_recaptcha_fields( array $captcha_settings, array $form_data ) {
$data = $this->get_recaptcha_data( $captcha_settings, $form_data );
$is_recaptcha = $captcha_settings['provider'] === 'recaptcha';
$is_recaptcha_v3 = $is_recaptcha && $captcha_settings['recaptcha_type'] === 'v3';
if ( $is_recaptcha_v3 ) {
// The value adds via JS code.
echo '<input type="hidden" name="wpforms[recaptcha]" value="">';
return;
}
echo '<div ' . wpforms_html_attributes( '', [ 'g-recaptcha' ], $data ) . '></div>';
if ( $is_recaptcha && $captcha_settings['recaptcha_type'] === 'invisible' ) {
return;
}
printf(
'<input type="text" name="g-recaptcha-hidden" class="wpforms-recaptcha-hidden" style="position:absolute!important;clip:rect(0,0,0,0)!important;height:1px!important;width:1px!important;border:0!important;overflow:hidden!important;padding:0!important;margin:0!important;" data-rule-%1$s="1">',
esc_attr( $captcha_settings['provider'] )
);
}
/**
* Get captcha settings for form output.
* Return null if captcha is disabled.
*
* @since 1.8.1
*
* @param array $form_data Form data and settings.
*
* @return array|null
* @noinspection NullPointerExceptionInspection
*/
private function get_form_captcha_settings( $form_data ) {
$captcha_settings = wpforms_get_captcha_settings();
if (
empty( $captcha_settings['provider'] ) ||
$captcha_settings['provider'] === 'none' ||
empty( $captcha_settings['site_key'] ) ||
empty( $captcha_settings['secret_key'] )
) {
return null;
}
// Check that the CAPTCHA is configured for the specific form.
if (
! isset( $form_data['settings']['recaptcha'] ) ||
$form_data['settings']['recaptcha'] !== '1'
) {
return null;
}
$is_recaptcha_v3 = $captcha_settings['provider'] === 'recaptcha' && $captcha_settings['recaptcha_type'] === 'v3';
if ( wpforms()->obj( 'amp' )->output_captcha( $is_recaptcha_v3, $captcha_settings, $form_data ) ) {
return null;
}
return $captcha_settings;
}
/**
* Google reCAPTCHA no-conflict mode.
*
* When enabled in the WPForms settings, forcefully remove all other
* reCAPTCHA enqueues to prevent conflicts. Filter can be used to target
* specific pages, etc.
*
* @since 1.4.5
* @since 1.6.4 Added hCaptcha support.
*/
public function recaptcha_noconflict() {
$captcha_settings = wpforms_get_captcha_settings();
if (
empty( $captcha_settings['provider'] ) ||
$captcha_settings['provider'] === 'none' ||
empty( wpforms_setting( 'recaptcha-noconflict' ) ) ||
/**
* Filters recaptcha no conflict flag.
*
* @since 1.6.4
*
* @param bool $recaptcha_no_conflict No conflict flag.
*/
! apply_filters( 'wpforms_frontend_recaptcha_noconflict', true ) // phpcs:ignore WPForms.PHP.ValidateHooks.InvalidHookName
) {
return;
}
$scripts = wp_scripts();
$urls = [ 'google.com/recaptcha', 'gstatic.com/recaptcha', 'hcaptcha.com/1' ];
foreach ( $scripts->queue as $handle ) {
// Skip the WPForms javascript-assets.
if (
! isset( $scripts->registered[ $handle ] ) ||
false !== strpos( $scripts->registered[ $handle ]->handle, 'wpforms' )
) {
return;
}
foreach ( $urls as $url ) {
if ( false !== strpos( $scripts->registered[ $handle ]->src, $url ) ) {
wp_dequeue_script( $handle );
wp_deregister_script( $handle );
break;
}
}
}
}
/**
* Load the assets needed for the CAPTCHA.
*
* @since 1.6.2
* @since 1.6.4 Added hCaptcha support.
*
* @param array $forms Forms being displayed.
*/
public function assets_recaptcha( $forms ) {
$captcha_settings = $this->get_assets_captcha_settings( $forms );
if ( ! $captcha_settings ) {
return;
}
$is_recaptcha_v3 = $captcha_settings['provider'] === 'recaptcha' && $captcha_settings['recaptcha_type'] === 'v3';
$recaptcha_url = $is_recaptcha_v3 ?
'https://www.google.com/recaptcha/api.js?render=' . $captcha_settings['site_key'] :
/**
* For backward compatibility reason we have to filter only the v2 reCAPTCHA.
*
* @since 1.4.0
*
* @param string $url The reCaptcha v2 URL.
*/
apply_filters( 'wpforms_frontend_recaptcha_url', 'https://www.google.com/recaptcha/api.js?onload=wpformsRecaptchaLoad&render=explicit' ); // phpcs:ignore WPForms.PHP.ValidateHooks.InvalidHookName
$captcha_api_array = [
'hcaptcha' => 'https://hcaptcha.com/1/api.js?onload=wpformsRecaptchaLoad&render=explicit',
'recaptcha' => $recaptcha_url,
'turnstile' => 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=wpformsRecaptchaLoad&render=explicit',
];
/**
* Filter the CAPTCHA API URL.
*
* @since 1.6.4
*
* @param string $captcha_api The CAPTCHA API URL.
*/
$captcha_api = apply_filters( 'wpforms_frontend_captcha_api', $captcha_api_array[ $captcha_settings['provider'] ] );
$in_footer = ! wpforms_is_frontend_js_header_force_load();
wp_enqueue_script(
'wpforms-recaptcha',
$captcha_api,
$is_recaptcha_v3 ? [] : [ 'jquery' ],
null, // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion
$in_footer
);
/**
* Filter the string containing the CAPTCHA JavaScript to be added.
*
* @since 1.6.4
*
* @param string $captcha_inline The CAPTCHA JavaScript.
*/
$captcha_inline = apply_filters(
'wpforms_frontend_captcha_inline_script',
$this->get_captcha_inline_script( $captcha_settings )
);
wp_add_inline_script( 'wpforms-recaptcha', $captcha_inline );
}
/**
* Get captcha settings for assets output.
* Return null if captcha is disabled.
*
* @since 1.8.1
*
* @param array $forms Forms being displayed.
*
* @return array|null
* @noinspection NullPointerExceptionInspection
*/
private function get_assets_captcha_settings( $forms ) {
/**
* Filters disable captcha switch.
*
* @since 1.6.2
*
* @param bool $is_captcha_disabled Whether captcha is disabled.
*/
if ( apply_filters( 'wpforms_frontend_recaptcha_disable', false ) ) { // phpcs:ignore WPForms.PHP.ValidateHooks.InvalidHookName
return null;
}
// Load CAPTCHA support if form supports it.
$captcha_settings = wpforms_get_captcha_settings();
if (
empty( $captcha_settings['provider'] ) ||
$captcha_settings['provider'] === 'none' ||
empty( $captcha_settings['site_key'] ) ||
empty( $captcha_settings['secret_key'] )
) {
return null;
}
// Whether at least 1 form on a page has CAPTCHA enabled.
$captcha = false;
foreach ( $forms as $form ) {
if ( ! empty( $form['settings']['recaptcha'] ) ) {
$captcha = true;
break;
}
}
// Return early.
if ( ! $captcha && ! wpforms()->obj( 'frontend' )->assets_global() ) {
return null;
}
return $captcha_settings;
}
/**
* Retrieve the string containing the CAPTCHA inline javascript.
*
* @since 1.6.4
*
* @param array $captcha_settings The CAPTCHA settings.
*
* @return string
* @noinspection JSUnusedLocalSymbols
* @noinspection UnnecessaryLocalVariableJS
* @noinspection JSUnresolvedVariable
* @noinspection JSDeprecatedSymbols
* @noinspection JSUnresolvedFunction
*/
protected function get_captcha_inline_script( $captcha_settings ) {
// IE11 polyfills for native `matches()` and `closest()` methods.
$polyfills = /** @lang JavaScript */
'if (!Element.prototype.matches) {
Element.prototype.matches = Element.prototype.msMatchesSelector || Element.prototype.webkitMatchesSelector;
}
if (!Element.prototype.closest) {
Element.prototype.closest = function (s) {
var el = this;
do {
if (Element.prototype.matches.call(el, s)) { return el; }
el = el.parentElement || el.parentNode;
} while (el !== null && el.nodeType === 1);
return null;
};
}
';
// Native equivalent for jQuery's `trigger()` method.
$dispatch = /** @lang JavaScript */
'var wpformsDispatchEvent = function (el, ev, custom) {
var e = document.createEvent(custom ? "CustomEvent" : "HTMLEvents");
custom ? e.initCustomEvent(ev, true, true, false) : e.initEvent(ev, true, true);
el.dispatchEvent(e);
};
';
// Update container class after changing Turnstile type.
$turnstile_update_class = /** @lang JavaScript */
'var turnstileUpdateContainer = function (el) {
let form = el.closest( "form" ),
iframeWrapperHeight = el.offsetHeight;
parseInt(iframeWrapperHeight) === 0 ?
form.querySelector(".wpforms-is-turnstile").classList.add( "wpforms-is-turnstile-invisible" ) :
form.querySelector(".wpforms-is-turnstile").classList.remove( "wpforms-is-turnstile-invisible" );
};
';
// Captcha callback, used by hCaptcha and checkbox reCaptcha v2.
$callback = /** @lang JavaScript */
'var wpformsRecaptchaCallback = function (el) {
var hdn = el.parentNode.querySelector(".wpforms-recaptcha-hidden");
var err = el.parentNode.querySelector("#g-recaptcha-hidden-error");
hdn.value = "1";
wpformsDispatchEvent(hdn, "change", false);
hdn.classList.remove("wpforms-error");
err && hdn.parentNode.removeChild(err);
};
';
$sync = /** @lang JavaScript */
'const wpformsRecaptchaSync = ( func ) => {
return function() {
const context = this;
const args = arguments;
// Sync with jQuery ready event.
jQuery( document ).ready( function() {
func.apply( context, args );
} );
}
};
';
if ( $captcha_settings['provider'] === 'hcaptcha' ) {
$data = $dispatch;
$data .= $callback;
$data .= /** @lang JavaScript */
'var wpformsRecaptchaLoad = function () {
Array.prototype.forEach.call(document.querySelectorAll(".g-recaptcha"), function (el) {
var captchaID = hcaptcha.render(el, {
callback: function () {
wpformsRecaptchaCallback(el);
}
});
el.setAttribute("data-recaptcha-id", captchaID);
});
wpformsDispatchEvent(document, "wpformsRecaptchaLoaded", true);
};
';
return $data;
}
if ( $captcha_settings['provider'] === 'turnstile' ) {
$data = $dispatch;
$data .= $callback;
$data .= $turnstile_update_class;
$data .= /** @lang JavaScript */
'var wpformsRecaptchaLoad = function () {
Array.prototype.forEach.call(document.querySelectorAll(".g-recaptcha"), function (el) {
let form = el.closest( "form" ),
formId = form.dataset.formid,
captchaID = turnstile.render(el, {
theme: "' . $captcha_settings['theme'] . '",
callback: function () {
turnstileUpdateContainer(el);
wpformsRecaptchaCallback(el);
},
"timeout-callback": function() {
turnstileUpdateContainer(el);
}
});
el.setAttribute("data-recaptcha-id", captchaID);
});
wpformsDispatchEvent( document, "wpformsRecaptchaLoaded", true );
};
';
return $data;
}
if ( $captcha_settings['recaptcha_type'] === 'v3' ) {
$data = $dispatch;
$data .= /** @lang JavaScript */
'var wpformsRecaptchaV3Execute = function ( callback ) {
grecaptcha.execute( "' . $captcha_settings['site_key'] . '", { action: "wpforms" } ).then( function ( token ) {
Array.prototype.forEach.call( document.getElementsByName( "wpforms[recaptcha]" ), function ( el ) {
el.value = token;
} );
if ( typeof callback === "function" ) {
return callback();
}
} );
}
grecaptcha.ready( function () {
wpformsDispatchEvent( document, "wpformsRecaptchaLoaded", true );
} );
';
} elseif ( $captcha_settings['recaptcha_type'] === 'invisible' ) {
$data = $polyfills;
$data .= $dispatch;
$data .= $sync;
$data .= /** @lang JavaScript */
'var wpformsRecaptchaLoad = wpformsRecaptchaSync( function () {
Array.prototype.forEach.call(document.querySelectorAll(".g-recaptcha"), function (el) {
try {
var recaptchaID = grecaptcha.render(el, {
"callback": function () {
wpformsRecaptchaCallback(el);
},
"error-callback": function () {
wpformsRecaptchaErrorCallback(el);
}
}, true);
el.closest("form").querySelector("button[type=submit]").recaptchaID = recaptchaID;
} catch (error) {}
});
wpformsDispatchEvent(document, "wpformsRecaptchaLoaded", true);
} );
var wpformsRecaptchaCallback = function (el) {
var $form = el.closest("form");
if (typeof wpforms.formSubmit === "function") {
wpforms.formSubmit($form);
} else {
$form.querySelector("button[type=submit]").recaptchaID = false;
$form.submit();
}
};
var wpformsRecaptchaErrorCallback = function (el) {
var $form = el.closest("form");
$form.querySelector("button[type=submit]").dataset.captchaInvalid = true;
};
';
} else {
$data = $dispatch;
$data .= $callback;
$data .= /** @lang JavaScript */
'var wpformsRecaptchaLoad = function () {
Array.prototype.forEach.call(document.querySelectorAll(".g-recaptcha"), function (el) {
try {
var recaptchaID = grecaptcha.render(el, {
callback: function () {
wpformsRecaptchaCallback(el);
}
});
el.setAttribute("data-recaptcha-id", recaptchaID);
} catch (error) {}
});
wpformsDispatchEvent(document, "wpformsRecaptchaLoaded", true);
};
';
}
return $data;
}
/**
* Cloudflare Turnstile captcha requires defer attribute.
*
* @since 1.8.1
*
* @param string $tag HTML for the script tag.
* @param string $handle Handle of script.
* @param string $src Src of script.
*
* @return string
*/
public function set_defer_attribute( $tag, $handle, $src ) {
$captcha_settings = wpforms_get_captcha_settings();
if ( $captcha_settings['provider'] !== 'turnstile' ) {
return $tag;
}
if ( $handle !== 'wpforms-recaptcha' ) {
return $tag;
}
return str_replace( ' src', ' defer src', $tag );
}
}