diff --git a/config/install/recaptcha.settings.yml b/config/install/recaptcha.settings.yml index 4591931..91b97b7 100644 --- a/config/install/recaptcha.settings.yml +++ b/config/install/recaptcha.settings.yml @@ -6,5 +6,5 @@ widget: theme: 'light' type: 'image' size: '' + badge: 'bottomright' tabindex: 0 - noscript: false diff --git a/config/schema/recaptcha.schema.yml b/config/schema/recaptcha.schema.yml index dcc3b57..0a95764 100644 --- a/config/schema/recaptcha.schema.yml +++ b/config/schema/recaptcha.schema.yml @@ -29,9 +29,9 @@ recaptcha.settings: size: type: string label: 'Size' + badge: + type: string + label: 'Badge' tabindex: type: integer label: 'Tabindex' - noscript: - type: boolean - label: 'Enable fallback for browsers with JavaScript disabled' diff --git a/js/recaptcha.invisible.js b/js/recaptcha.invisible.js new file mode 100644 index 0000000..8093b6f --- /dev/null +++ b/js/recaptcha.invisible.js @@ -0,0 +1,125 @@ +/** + * @file + * Invisible reCaptcha behaviors. + */ + +/* globals grecaptcha*/ +/* eslint-disable no-unused-vars*/ +/** + * The submit object that was clicked. + * + * @type {object} + */ +var clickedSubmit; +var clickedSubmitEvent; +var clickedSubmitAjaxEvent; + + +/** + * reCaptcha data-callback that submits the form. + * + */ +function recaptchaOnInvisibleSubmit() { + 'use strict'; + jQuery(clickedSubmit).unbind('.recaptcha'); + if (clickedSubmitAjaxEvent) { + jQuery(clickedSubmit).trigger(clickedSubmitAjaxEvent); + } + else { + jQuery(clickedSubmit).click(); + } + clickedSubmitEvent = clickedSubmit = clickedSubmitAjaxEvent = ''; +} + +(function ($, Drupal) { + 'use strict'; + + /** + * Handles the submission of the form with the invisible reCaptcha. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches the behavior for the invisible reCaptcha. + */ + Drupal.behaviors.invisibleRecaptcha = { + attach: function (context) { + if (Drupal.hasOwnProperty('Ajax')) { + var originalBeforeSubmit = Drupal.Ajax.prototype.beforeSubmit; + Drupal.Ajax.prototype.beforeSubmit = function (form_values, element, options) { + if (this.event === 'mousedown' && $(this.element).data('recaptcha-submit') && grecaptcha.getResponse().length === 0) { + clickedSubmit = this.element; + options.needsRevalidate = true; + this.progress.type = 'none'; + clickedSubmitAjaxEvent = this.event; + } + + originalBeforeSubmit.apply(this, arguments); + }; + + if (!$(document).data('invisible-recaptcha-ajax-send-processed')) { + $(document).ajaxSend(function (event, jqxhr, settings) { + if (settings.needsRevalidate) { + jqxhr.abort(); + $(clickedSubmit).prop('disabled', false); + } + }); + + $(document).data('invisible-recaptcha-ajax-send-processed', true); + } + } + $('form', context).each(function () { + var $form = $(this); + if ($form.find('.g-recaptcha[data-size="invisible"]').length) { + $form.find(':submit').data('recaptcha-submit', true).on({ + 'mousedown.recaptcha': function (e) { + preventFormSubmit(this, e); + }, + 'click.recaptcha': function (e) { + preventFormSubmit(this, e); + } + }); + } + }); + + /** + * Prevent form submit if recaptcha is not valid. + * + * @param {Object} elem - Triggering element. + * @param {Object} event - Triggering event. + */ + function preventFormSubmit(elem, event) { + if (grecaptcha.getResponse().length === 0) { + // We need validate form, to avoid prevention of html5 validation. + var form = $(elem).closest('form')[0]; + if (form && typeof form.checkValidity === 'function') { + if (form.checkValidity()) { + event.preventDefault(); + event.stopPropagation(); + clickedSubmitEvent = event.type; + validateInvisibleCaptcha(elem); + } + else { + if (typeof form.reportValidity === 'function') { + form.reportValidity(); + } + } + } + else { + validateInvisibleCaptcha(elem); + } + } + } + + /** + * Triggers the reCaptcha to validate the form. + * + * @param {object} button - The submit button object was clicked. + */ + function validateInvisibleCaptcha(button) { + clickedSubmit = button; + grecaptcha.execute(); + } + } + }; +})(jQuery, Drupal); diff --git a/js/recaptcha.js b/js/recaptcha.js new file mode 100644 index 0000000..a9a5912 --- /dev/null +++ b/js/recaptcha.js @@ -0,0 +1,34 @@ +/** + * @file + * Contains the definition of the behaviour recaptcha. + */ + +(function ($, Drupal) { + Drupal.behaviors.recaptcha = { + attach: function (context) { + $('.g-recaptcha', context).each(function () { + if (typeof grecaptcha === 'undefined' || typeof grecaptcha.render !== 'function') { + return; + } + if ($(this).closest('body').length > 0) { + if ($(this).hasClass('recaptcha-processed')) { + grecaptcha.reset(); + } + else { + grecaptcha.render(this, $(this).data()); + $(this).addClass('recaptcha-processed'); + } + } + }); + } + }; + + window.drupalRecaptchaOnload = function () { + $('.g-recaptcha').each(function () { + if (!$(this).hasClass('recaptcha-processed')) { + grecaptcha.render(this, $(this).data()); + $(this).addClass('recaptcha-processed'); + } + }); + }; +})(jQuery, Drupal); diff --git a/migration_templates/d6_recaptcha_settings.yml b/migration_templates/d6_recaptcha_settings.yml index 67283bc..a164c17 100644 --- a/migration_templates/d6_recaptcha_settings.yml +++ b/migration_templates/d6_recaptcha_settings.yml @@ -6,7 +6,6 @@ migration_groups: source: plugin: variable variables: - - recaptcha_noscript - recaptcha_site_key - recaptcha_size - recaptcha_secret_key @@ -21,7 +20,6 @@ process: 'widget/type': recaptcha_type 'widget/size': recaptcha_size 'widget/tabindex': recaptcha_tabindex - 'widget/noscript': recaptcha_noscript destination: plugin: config config_name: recaptcha.settings diff --git a/migration_templates/d7_recaptcha_settings.yml b/migration_templates/d7_recaptcha_settings.yml index 0bd295f..480dc47 100644 --- a/migration_templates/d7_recaptcha_settings.yml +++ b/migration_templates/d7_recaptcha_settings.yml @@ -6,7 +6,6 @@ migration_groups: source: plugin: variable variables: - - recaptcha_noscript - recaptcha_site_key - recaptcha_size - recaptcha_secret_key @@ -23,7 +22,6 @@ process: 'widget/type': recaptcha_type 'widget/size': recaptcha_size 'widget/tabindex': recaptcha_tabindex - 'widget/noscript': recaptcha_noscript destination: plugin: config config_name: recaptcha.settings diff --git a/recaptcha.libraries.yml b/recaptcha.libraries.yml new file mode 100644 index 0000000..3baef97 --- /dev/null +++ b/recaptcha.libraries.yml @@ -0,0 +1,14 @@ +recaptcha: + js: + js/recaptcha.js: {} + dependencies: + - core/drupal + - core/jquery + +recaptcha.invisible: + version: VERSION + js: + js/recaptcha.invisible.js: {} + dependencies: + - core/jquery + - core/drupal diff --git a/recaptcha.module b/recaptcha.module index 8e31d9f..6dcffe0 100644 --- a/recaptcha.module +++ b/recaptcha.module @@ -41,20 +41,6 @@ function recaptcha_help($route_name, RouteMatchInterface $route_match) { } } -/** - * Implements hook_theme(). - */ -function recaptcha_theme() { - return [ - 'recaptcha_widget_noscript' => [ - 'variables' => [ - 'widget' => NULL, - ], - 'template' => 'recaptcha-widget-noscript', - ], - ]; -} - /** * Implements hook_captcha(). */ @@ -89,23 +75,8 @@ function recaptcha_captcha($op, $captcha_type = '') { // Check if reCAPTCHA use globally is enabled. $recaptcha_src = 'https://www.google.com/recaptcha/api.js'; - $recaptcha_src_fallback = 'https://www.google.com/recaptcha/api/fallback'; if ($recaptcha_use_globally) { $recaptcha_src = 'https://www.recaptcha.net/recaptcha/api.js'; - $recaptcha_src_fallback = 'https://www.recaptcha.net/recaptcha/api/fallback'; - } - - $noscript = ''; - if ($config->get('widget.noscript')) { - $recaptcha_widget_noscript = [ - '#theme' => 'recaptcha_widget_noscript', - '#widget' => [ - 'sitekey' => $recaptcha_site_key, - 'recaptcha_src_fallback' => $recaptcha_src_fallback, - 'language' => \Drupal::service('language_manager')->getCurrentLanguage()->getId(), - ], - ]; - $noscript = $renderer->render($recaptcha_widget_noscript); } $attributes = [ @@ -116,19 +87,33 @@ function recaptcha_captcha($op, $captcha_type = '') { 'data-size' => $config->get('widget.size'), 'data-tabindex' => $config->get('widget.tabindex'), ]; + + // Set the callback for invisible captcha. + if ('invisible' === $config->get('widget.size')) { + $attributes['data-badge'] = $config->get('widget.badge'); + $attributes['data-callback'] = 'recaptchaOnInvisibleSubmit'; + $captcha['form']['#attached']['library'][] = 'recaptcha/recaptcha.invisible'; + } + // Filter out empty tabindex/size. $attributes = array_filter($attributes); $captcha['form']['recaptcha_widget'] = [ '#markup' => '', - '#suffix' => $noscript, '#attached' => [ 'html_head' => [ [ [ '#tag' => 'script', '#attributes' => [ - 'src' => Url::fromUri($recaptcha_src, ['query' => ['hl' => \Drupal::service('language_manager')->getCurrentLanguage()->getId()], 'absolute' => TRUE])->toString(), + 'src' => Url::fromUri($recaptcha_src, [ + 'query' => [ + 'hl' => \Drupal::service('language_manager')->getCurrentLanguage()->getId(), + 'onload' => 'drupalRecaptchaOnload', + 'render' => 'explicit', + ], + 'absolute' => TRUE, + ])->toString(), 'async' => TRUE, 'defer' => TRUE, ], @@ -136,6 +121,9 @@ function recaptcha_captcha($op, $captcha_type = '') { 'recaptcha_api', ], ], + 'library' => [ + 'recaptcha/recaptcha', + ], ], ]; } @@ -207,14 +195,3 @@ function recaptcha_captcha_validation($solution, $response, $element, $form_stat } return FALSE; } - -/** - * Process variables for recaptcha-widget-noscript.tpl.php. - * - * @see recaptcha-widget-noscript.tpl.php - */ -function template_preprocess_recaptcha_widget_noscript(&$variables) { - $variables['sitekey'] = $variables['widget']['sitekey']; - $variables['language'] = $variables['widget']['language']; - $variables['url'] = Url::fromUri($variables['widget']['recaptcha_src_fallback'], ['query' => ['k' => $variables['widget']['sitekey'], 'hl' => $variables['widget']['language']], 'absolute' => TRUE])->toString(); -} diff --git a/src/Form/ReCaptchaAdminSettingsForm.php b/src/Form/ReCaptchaAdminSettingsForm.php index 2029bf0..ef7c253 100644 --- a/src/Form/ReCaptchaAdminSettingsForm.php +++ b/src/Form/ReCaptchaAdminSettingsForm.php @@ -101,10 +101,41 @@ class ReCaptchaAdminSettingsForm extends ConfigFormBase { '#options' => [ '' => $this->t('Normal (default)'), 'compact' => $this->t('Compact'), + 'invisible' => $this->t('Invisible'), ], '#title' => $this->t('Size'), '#type' => 'select', ]; + $form['widget']['recaptcha_size_container_notice'] = [ + '#type' => 'container', + '#states' => array( + 'visible' => array( + ':input[name="recaptcha_size"]' => array('value' => 'invisible'), + ), + ), + ]; + $link_to_captcha_settings = Url::fromRoute('captcha_settings')->toString(); + $message = t('Uncheck Add a description to the CAPTCHA in CAPTCHA settings or a fieldset with a description will be displayed to users.', [':link_to_captcha_settings' => $link_to_captcha_settings]); + $form['widget']['recaptcha_size_container_notice']['invisible'] = [ + '#type' => 'markup', + '#markup' => '

' . $message . '

', + ]; + $form['widget']['recaptcha_badge'] = [ + '#default_value' => $config->get('widget.badge'), + '#description' => $this->t('Reposition the reCAPTCHA badge. "Inline" allows you to control the CSS.'), + '#options' => [ + 'bottomright' => $this->t('Bottom Right (default)'), + 'bottomleft' => $this->t('Bottom Left'), + 'inline' => $this->t('Inline'), + ], + '#title' => $this->t('Badge'), + '#type' => 'select', + '#states' => array( + 'visible' => array( + ':input[name="recaptcha_size"]' => array('value' => 'invisible'), + ), + ), + ]; $form['widget']['recaptcha_tabindex'] = [ '#default_value' => $config->get('widget.tabindex'), '#description' => $this->t('Set the tabindex of the widget and challenge (Default = 0). If other elements in your page use tabindex, it should be set to make user navigation easier.', [':tabindex' => Url::fromUri('https://www.w3.org/TR/html4/interact/forms.html', ['fragment' => 'adef-tabindex'])->toString()]), @@ -113,12 +144,6 @@ class ReCaptchaAdminSettingsForm extends ConfigFormBase { '#type' => 'number', '#min' => -1, ]; - $form['widget']['recaptcha_noscript'] = [ - '#default_value' => $config->get('widget.noscript'), - '#description' => $this->t('If JavaScript is a requirement for your site, you should not enable this feature. With this enabled, a compatibility layer will be added to the captcha to support non-js users.'), - '#title' => $this->t('Enable fallback for browsers with JavaScript disabled'), - '#type' => 'checkbox', - ]; return parent::buildForm($form, $form_state); } @@ -136,8 +161,8 @@ class ReCaptchaAdminSettingsForm extends ConfigFormBase { ->set('widget.theme', $form_state->getValue('recaptcha_theme')) ->set('widget.type', $form_state->getValue('recaptcha_type')) ->set('widget.size', $form_state->getValue('recaptcha_size')) + ->set('widget.badge', $form_state->getValue('recaptcha_badge')) ->set('widget.tabindex', $form_state->getValue('recaptcha_tabindex')) - ->set('widget.noscript', $form_state->getValue('recaptcha_noscript')) ->save(); parent::submitForm($form, $form_state); diff --git a/templates/recaptcha-widget-noscript.html.twig b/templates/recaptcha-widget-noscript.html.twig deleted file mode 100644 index b1737fe..0000000 --- a/templates/recaptcha-widget-noscript.html.twig +++ /dev/null @@ -1,29 +0,0 @@ -{# -/** - * @file recaptcha-widget-noscript.tpl.php - * Default theme implementation to present the reCAPTCHA noscript code. - * - * Available variables: - * - sitekey: Google web service site key. - * - language: Current site language code. - * - url: Google web service API url. - * - * @see template_preprocess() - * @see template_preprocess_recaptcha_widget_noscript() - * - * @ingroup themeable - */ -#} - - diff --git a/tests/src/Functional/ReCaptchaBasicTest.php b/tests/src/Functional/ReCaptchaBasicTest.php index 1189ac2..9746d77 100644 --- a/tests/src/Functional/ReCaptchaBasicTest.php +++ b/tests/src/Functional/ReCaptchaBasicTest.php @@ -141,28 +141,29 @@ class ReCaptchaBasicTest extends BrowserTestBase { // Check if there is a reCAPTCHA on the login form. $this->drupalGet('user/login'); $this->assertSession()->responseContains($grecaptcha, '[testReCaptchaOnLoginForm]: reCAPTCHA is shown on form.'); - $this->assertSession()->responseContains('', '[testReCaptchaOnLoginForm]: reCAPTCHA is shown on form.'); - $this->assertSession()->responseNotContains($grecaptcha . '