Невидимая Google reCAPTCHA V2 – несколько форм на странице и в модальных AJAX окнах – UMI.CMS

10.08.2020

Исходные данные

Есть сайт на UMI.CMS. PHP шаблонизатор

Задача

Подключить Google reCAPTCHA для всех форм на сайте: для форм в контенте и модальных окнах. Форм на одной странице может быть несколько. Нужно защитить все.

Решение

Логика работы следующая:

  • Загружается страница или модальное окно через AJAX;
  • Для загруженной формы подключается reCAPTCHA;
  • При попытке отправить форму происходит проверка на спамность;
  • Если проверка пройдена – вызывается JS функция, которая AJAX-ом отправляет форму;
  • Если пользователя заподозрили в спамности, ему предлагают пройти задание задание, например: найти что-то на картинке;
  • Когда задание пройдено, вызывается JS функция отправки формы, указанная в качестве callback-функции.

Код приведен для отправки отзыва о товаре в модальном окне. Отзывы сохраняются в модуль Комментарии.

1. Добавляем новый сайт в консоль

Регистрируем сайт в консоле. Выбираем:

Тип reCAPTCHA:Версия 2 – невидимый значок

Этот тип рекапчи показывает задания только тогда, когда пользователя подозревают в спамности.

2. Подключаем скрипт reCAPTCHA на сайт

Добавляем в самом конце тега <body> (строго после других скриптов):

<script src="https://www.google.com/recaptcha/api.js?onload=recaptchaOnloadCallback&render=explicit&hl=ru" async defer></script>

3. Функция для добавления рекапчи в форму

Название функции – это параметр onload из пункта 2

sitekey – нужно подставить свой из пункта 1. Это: “КЛЮЧ САЙТА”

let _captchaTries = 0; // количество попыток добавить рекапчу
function getTries() {
	_captchaTries++;

	return _captchaTries;
}
// функция добавляющая капчи в формы
function recaptchaOnloadCallback(tries = getTries()) {
	if (tries > 3) return; // пробуем добавить 3 раза через 1 секунду (если форма сразу не прогрузились)
	let recaptchas = document.querySelectorAll('div[class=g-recaptcha]'); // все блоки рекапчи на странице
	let recaptchaId;
	for (let i = 0; i < recaptchas.length; i++) { // проходим все формы
		let element = recaptchas[i]; // текущий блок рекапчи
		let form = element.closest('form'); // текущая форма
		if (element.childNodes.length === 0) { // если блок рекапчи пустой
			recaptchaId = grecaptcha.render(element, { // добавляем рекапчу в форму и получаем ее ID
				sitekey: '6LdTunEUAAAAAKQxNE8m9VwPRpohPIDIDXyX9wBh', // публичный ключ
				size: 'invisible', // версия - невидимая
				badge: 'inline', // значок рекапчи в форме
				callback: function (token) {
					site.forms.checkRecaptcha(form); // функция которая вызовется при успешном выполнении задания
				},
			});
			element.dataset.widgetId = recaptchaId; // тегу рекапчи добавляем data атрибут с ID рекапчи
		}
	}
	window.setTimeout(recaptchaOnloadCallback, 1000); // пробуем еще раз через секунду
}

window.recaptchaOnloadCallback = recaptchaOnloadCallback;

4. Добавляем кнопку вызова формы

<button class="js_getajax_popup" data-module="comments" data-template="post" data-micromodal-trigger="add-review">Оставить отзыв</button>

5. Обработчик вызова формы

$(document).on('click', 'js_getajax_popup', function (e) {
	e.preventDefault();
	const $this = $(this);
	const id = $this.data('micromodal-trigger'); // ID блока
	const $modal = $(`#${id}`);
	const module = $this.data('module'); // модуль
	const template = $this.data('template'); // шаблон
	const templateString = `${module}/${template}`; // путь к шаблону в папке директории ajax
	$modal.text(''); // очищаем блок на всякий случай
	$.ajax({
		url: '/',
		dataType: 'html',
		data: {
			template: templateString,
		},
		async: true,
		cache: true,
		type: 'GET',
		success(html) { // отправка удалась
			$modal.html(html); // пишем форму в контейнер
			_captchaTries = 0; // обнуляем переменную
			recaptchaOnloadCallback(); // подключаем рекапчу
		}, // конец успешной отправки
		error(xhr, ajaxOptions, thrownError) {
console.log(`${thrownError}\r\n${xhr.statusText}\r\n${xhr.responseText}`);
		},
	});
});

6. Обработчик отправки формы

$(document).on('submit', '.js_ajax_form', function (e) {
	e.preventDefault();
	const $form = $(this);
	$form.find($('.errors')).html(''); // очищаем ошибки
	let recaptchaTag = $form.find($('.g-recaptcha')); // тег рекапчи
	let widgetId = recaptchaTag.data('widgetId'); // ID рекапчи
	grecaptcha.reset(widgetId); // обнуляем рекапчу
	if (recaptchaTag.is(':empty')) { // если капча не загружена
		console.log('Ошибка: капча не загружена');

		return false; // прекращаем выполнение
	}
	// валидация формы
	let countInvalidElements = site.forms.validateForm($form); // количество невалидных полей
	if (countInvalidElements > 0) { // если есть невалидные - прекращаем
		let container = $('.modal__container'); // модальное окно
		let	scrollTo = $form.find($('.is-invalid:first')); // первое невалидное поле
		// скролл к первому невалидному полю
		container.animate({scrollTop: scrollTo.offset().top - container.offset().top + container.scrollTop() - 70});

		return false; // прекращаем выполнение
	}
	grecaptcha.execute(widgetId); // вызываем рекапчу. Если пользователь подозревается в спаме - появится проерка.
	// если проверка будет успешно пройдена или ее не будет - вызовется callback функция

	return false;
});

7. Проверка рекапчи

Это callback функция из пункта 3

// функция проверяющая капчу
checkRecaptcha: (form) => {
	const $form = $(form); // форма
	const recaptchaTag = $form.find($('.g-recaptcha')); // тег рекапчи
	const widgetId = recaptchaTag.data('widgetId'); // ID рекапчи
	const module = $form.data('module'); // модуль
	// отправка запроса на проверку рекапчи
	$.ajax({
		url: '/',
		method: 'POST',
		dataType: 'json',
		data: {
			'g-recaptcha-response': grecaptcha.getResponse(widgetId),
			'template': 'get-recaptcha', // название шаблона в директории php/ajax/templates
		}, // отправляем только данные для капчи
		beforeSend: function (xhr) { // что делать перед отправкой
			// container.addClass('ajax-loader'); // Show our loader
		},
		success: function (response) { // отправка успешна
			if (response.success === true) { // если капча не нужна
				if (module === 'comments') { // если форма отправки комментария
					site.forms.sendComments($form); // вызываем функцию отправки
				}
			} else { // если нужна Капча
				grecaptcha.reset(widgetId); // резетим капчю
				grecaptcha.execute(widgetId); // вызываем капчу
				$form.find($('.errors')).html('Ошибка при проверке на спам. Попробуйте отправить еще раз. Если не получится - перезагрузите страницу.'); // пишем ошибку
			}
		},
	});

	return false;
}

8. PHP функция проверки рекапчи

В файл \templates\xxxxx\php\ajax\templates\get-recaptcha.phtml добавляем (заменить RECAPTCHA_SECRET_KEY на “СЕКРЕТНЫЙ КЛЮЧ” из пункта 1):

<?php /** @var umiTemplaterPHP|ViewPhpExtension|UpmixExtension  $this */?>
<?php /** @var array $variables */?>
<?php
use UmiCms\Service;
define('RECAPTCHA_SECRET_KEY', '6LdTunEUAAAAAEjOIRevQSSyaHaqFYCM_BvEC5Vq');
// json response helper
$json_response = function($data = []) {
	$buffer = Service::Response()
		->getCurrentBuffer();
	/* @var HTTPOutputBuffer $buffer*/
	$buffer->contentType('application/x-www-form-urlencoded; charset=utf-8');
	$buffer->option('generation-time', false);
	$buffer->push(json_encode($data, JSON_THROW_ON_ERROR));
	$buffer->send();
	exit;
};

// handle post
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
	// define errors array
	$errors = [];
	// check g-recaptcha-response
	if (!isset($_POST['g-recaptcha-response'])) {
		$errors['recaptcha'] = 'Check the captcha form';
	}
	// if all good call API else error out
	if (!empty($errors)) {
		$json_response(['errors' => $errors]);
	}
	// call recaptcha site verify
	$response = file_get_contents(
		'https://www.google.com/recaptcha/api/siteverify?'.http_build_query([
			'secret'   => RECAPTCHA_SECRET_KEY,
			'response' => $_POST['g-recaptcha-response'],
			'remoteip' => $_SERVER['REMOTE_ADDR'],
		])
	);
	$response = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
	// handle status and respond with json
	if ((int)$response['success'] !== 1) {
		$json_response(['errors' => ['recaptcha' => 'Captcha failed']]);
	} else {
		$json_response(['success' => true]);
	}
}

9. Отправка формы, когда рекапча пройдена

// функция отправляющая ajax форму комментариев
sendComments($form) {
	let formData = new FormData(); // данные формы
	$.each($('input[type=file]')[0].files, function(key, input) {
		formData.append('files[]', input); // добавляем данные из полей типа "Файл"
	});
	let dataArray = $form.serializeArray(); // остальные поля
	for (let i = 0; i < dataArray.length; i++) {
		formData.append(dataArray[i].name, dataArray[i].value); // итоговый массив данных
	}
	formData.append('template', 'comments/postReview'); // шаблон формы
	formData.append('pageId', window.pageData.pageId); // ID текущей страницы из массива в <head>
	$.ajax({
		url: '/',
		type: 'POST',
		data: formData,
		dataType: 'text',
		processData: false,
		contentType: false,
		cache: false,
		success: function (data) { // успешная отправка
			$form.find(':input').attr('disabled', false);
			$form.trigger('reset'); // сброс формы
			$('#overlay').fadeOut(); // убираем лоадер
			if (data === 'success') {
				$form.html('Отзыв отправлен.<br/>После проверки модератором он будет размещен на сайте');
				let micromodalId = $form.data('micromodal-trigger');
				setTimeout(function () {
					MicroModal.close(micromodalId); // закрываем модальное окно
				}, 3000);
			} else {
				$form.find($('.errors')).html('Произошла ошибка.<br/> Попробуйте еще раз. <br/> Если не получится - обновите страницу. <br/>Или свяжитесь с нами');
			}
		},
		beforeSend: function () { // перед отправкой
			$('#overlay').fadeIn(); // показываем прелоадер
			$form.find(':input').attr('disabled', true); // отключаем все поля
		},
	});

	return false;
},

10. PHP шаблон отправки AJAX формы

<?php /** @var umiTemplaterPHP|ViewPhpExtension|UpmixExtension  $this */?>
<?php /** @var array $variables */?>
<?php
$pageId = getRequest('pageId');
$html = $this->macros('comments', 'postReview', array($pageId));
$this->sendAjaxResponse($html);

11. Опционные функции для валидации полей формы

/**
 * валидация формы
 * // @returns {}
 */
validateForm(form) {
	let count = 0; // количество невалидных полей
	form.find('input').filter('[required]').each((index, element) => { // поля с атрибутов required
		let $element = $(element);
		let phoneValue;
		let emailValue;
		$element.removeClass('is-invalid'); // убираем сообщения об ошибках
		switch ($element.attr('type')) { // для различных типов полей
			case 'text':
				// поля для ввода email type text
				if ($element.attr('name') === 'author_email') {
					emailValue = $element.val();
					if (site.forms.validateEmail(emailValue) === false) {
						$element.addClass('is-invalid');
						count++;
					}
				} else if ($element.val().length < 2) { // если введено меньше 2-х символов
					$element.addClass('is-invalid');
					count++;
				}
				break;
			case 'tel':
				phoneValue = $element.inputmask('unmaskedvalue'); // телефон без маски
				if (typeof phoneValue === 'undefined' || phoneValue.length < 11) {
					$element.addClass('is-invalid');
					count++;
				}
				break;
			case 'email': // поля для ввода email
				emailValue = $element.val();
				if (site.forms.validateEmail(emailValue) === false) {
					$element.addClass('is-invalid');
					count++;
				}
				break;
			case 'checkbox': // checkbox - согласие
				if ($element.prop('checked') === false) {
					$element.addClass('is-invalid');
					count++;
				}
				break;

			case 'password': // пароль
				if ($element.val().length < 6) { // если меньше 6 символов
					$element.addClass('is-invalid');
					count++;
				}
				break;
			default:
				break;
		}
	});
	form.find('textarea').filter('[required]').each((index, element) => {
		const $element = $(element);
		if ($element.val().length < 2) { // если введено меньше 2-х символов
			$element.addClass('is-invalid');
			count++;
		}
	});
	form.find('select').filter('[required]').each((index, element) => {
		const $element = $(element);
		if (!$element.val().length) { // если введено меньше 2-х символов
			$element.addClass('is-invalid');
			count++;
		}
	});
	site.forms.registerOnFocusInputCallback(); // убираем ошибку при фокусе

	return count;
},

/**
 * валидация email c поддержкой unicode
 * @returns {boolean}
 */
validateEmail(email) {
	const re = /^(([^<>()[\].,;:\s@"]+(\.[^<>()[\].,;:\s@"]+)*)|(".+"))@(([^<>()[\].,;:\s@"]+\.)+[^<>()[\].,;:\s@"]{2,})$/i;

	return re.test(String(email).toLowerCase());
},

registerOnFocusInputCallback: () => { // при фокусе на поле убираем невалидность
	$('input.is-invalid').on('focus', function () {
		$(this).removeClass('is-invalid');
	});
},

/** Добавляет маску формата телефонного номера в поля с номером телефона */
initInputmask: () => {
	$('input[type="tel"]').inputmask('+9-999-999-99-99');
},

Comments 0

Добавить комментарий

Your email address will not be published. Required fields are marked *