FormLister 1.5: новые капчи

Писать про изменения и новые баги улучшения я не буду, потому как это не очень интересно — можно глянуть здесь. Главное, что добавлена поддержка Twig, чтобы облегчить работу со сложными формами и переделана работа с капчами, про капчи и напишу.

Сам я капчами не пользуюсь, но около месяца назад возникла проблема с компонентом SimplePolls: участники голосования бессовестно накручивали голоса руками школьников из Китая и Нигерии, против чего оказалась бессильна обычная Evo-капча с разноцветными циферками.

Поразмыслив, было принято решение подтверждать голоса смс-кодом: во-первых, это самый дорогой вариант для накрутки; во-вторых, при обнаружении накрутки можно отсеять часть голосов по номерам телефонов. Недостаток решения заключается в том, что есть возможность слить бюджет, отправляя смс на случайные номера.

До этого я уже делал смс-авторизацию для tiptop , поэтому задача усложнилась с целью получить на выходе что-то более-менее универсальное, не ограничиваясь только голосовалкой.

По смыслу смс-подтверждение похоже на капчу: генерируется какой-то код и сравнивается с тем, что ввел пользователь. Исходя из этого я переписал часть FormLister, которая отвечает за работу с капчами. В итоге процесс работы с смс стал выглядеть так: в форме есть поле для ввода кода и ссылка, по которой открывается другая форма, где пользователь вводит номер телефона и получает смс с кодом. Телефоны и коды заносятся в базу и предусмотрено ограничение срока действия кода: нельзя запросить код повторно, не использовав предыдущий в указанный срок; для использованного кода так же задается ограничение по времени.

Пример основной формы:

[!FormLister?
&formid=`basic`
&config=`default:core`
&rules=`{
"name":{
	"required":"Обязательно введите имя"
},
"email":{
	"required":"Обязательно введите email",
	"email":"Введите email правильно"
},
"phone":{
	"required":"Обязательно введите номер телефона",
	"phone":"Введите номер правильно"
},
"message":{
	"required":"Обязательно введите сообщение"
}
}`
&captcha=`smsCaptcha`
&captchaParams=`{
"codeLifeTime":604800
}`
&captchaField=`smscode`
&formTpl=`@CODE:
<div class="row">
	<div class="col-md-8 col-md-offset-2">
		<div class="well">
			<form class="form-horizontal" method="post">
				<input type="hidden" name="formid" value="basic">
				<div class="form-group[+name.errorClass+][+name.requiredClass+]">
					<label for="name" class="col-sm-2 control-label">* Имя</label>
					<div class="col-sm-10">
						<input type="text" class="form-control" id="name" placeholder="Имя" name="name" value="[+name.value+]">
						[+name.error+]
					</div>
				</div>
				<div class="form-group[+email.errorClass+][+email.requiredClass+]">
					<label for="email" class="col-sm-2 control-label">* Email</label>
					<div class="col-sm-10">
						<input type="text" class="form-control" id="email" placeholder="Email" name="email" value="[+email.value+]">
						[+email.error+]
					</div>
				</div>
				<div class="form-group[+phone.errorClass+][+phone.requiredClass+]">
					<label for="phone" class="col-sm-2 control-label">* Телефон</label>
					<div class="col-sm-10">
						<input type="text" class="form-control" id="phone" placeholder="+375 29 123 45 67" name="phone" value="[+phone.value+]">
						[+phone.error+]
					</div>
				</div>

				<div class="form-group[+message.errorClass+][+message.requiredClass+]">
					<label for="message" class="col-sm-2 control-label">* Сообщение</label>
					<div class="col-sm-10">
						<textarea class="form-control" id="message" placeholder="Ваше сообщение" name="message" rows="10">[+message.value+]</textarea>
						[+message.error+]
					</div>
				</div>
				[+form.messages+]
				<div class="form-group">
					<label for="phone" class="col-sm-2 control-label">* Проверочный код</label>
					<div class="col-sm-10">
						<input type="text" class="form-control" name="smscode">
						[+smscode.error+]
					</div>
					<div><a href="[~139~]" class="getPhoneCode">Получить код авторизации</a></div>
				</div>
				<div class="form-group">
					<div class="col-sm-offset-2 col-sm-10">
						<button type="submit" class="btn btn-primary"><i class="glyphicon glyphicon-envelope"></i> Отправить</button>
					</div>
				</div>
			</form>
		</div>
	</div>
</div>`
!]

Здесь, как видно, пользователь может отправить эту форму 1 раз в 604800 секунд, используя один телефон. В примере простая форма, которая отправляет письмо, но ничто сильно не мешает использовать смс-капчу для подтверждения регистрации или авторизации.

Форма для получения кода:

[!FormLister?
&formid=`code`
&submitLimit=`0`
&protectSubmit=`0`
&config=`default:core`
&noemail=`1`
&rules=`{
"phone":{
	"required":"Обязательно введите номер телефона",
	"phone":"Введите номер правильно"
}
}`
&prepareProcess=`setSmsCaptcha`
&captcha=`modxCaptcha`
&formTpl=`@CODE:
<div class="row">
	<div class="col-md-8 col-md-offset-2">
		<div class="well">
			<form class="form-horizontal" method="post">
				<input type="hidden" name="formid" value="code">
				
				<div class="form-group[+phone.errorClass+][+phone.requiredClass+]">
					<label for="phone" class="col-sm-2 control-label">* Телефон</label>
					<div class="col-sm-10">
						<input type="text" class="form-control" placeholder="+375 29 123 45 67" name="phone" value="[+phone.value+]">
						[+phone.error+]
					</div>
				</div>
				[+form.messages+]
				<div class="form-group">
					<div class="col-sm-offset-2 col-sm-10">
						<button type="submit" class="btn btn-primary"><i class="glyphicon glyphicon-envelope"></i> Отправить</button>
					</div>
				</div>
				<img src="[+captcha+]">
				<input name="vericode">
				[+vericode.error+]
			</form>
		</div>
	</div>
</div>`
&successTpl=`@CODE:Код авторизации отправлен на номер [+phone.value+]. Срок действия кода - 3 минуты.`
!]

Так как эта форма работает через FormLister, то можно задать ограничения в виде капчи и параметров submitLimit и protectSubmit, чтобы немного усложнить слив бюджета. Можно даже сделать запрос смс для того, чтобы запросить смс (:

Генерация кода, отправка смс и регистрация телефона делается вручную, prepare-сниппетом:

<?php
//удаляем из номера телефона все, кроме цифр
$rawPhone = preg_replace('/[^\d]+/','',$data['phone']);
//задаем значение, по которому будет определяться для какой формы генерируется код
$formid = 'basic';
//в сессии будем хранить номер телефона, это нужно для проверки кода в основной форме, потом его можно использовать в каких-то целях, например убрать из основной формы поле для ввода телефона, а в письме использовать телефон из сессии
$session_key = $formid.'.smscaptcha';
if (empty($rawPhone)) {
    $FormLister->setValid(false);
    $FormLister->addError('phone','phone','Неверный номер телефона');	
} else {
    //загружаем класс для работы с таблицей
    $sms = $FormLister->loadModel('SmsModel','assets/snippets/FormLister/lib/captcha/smsCaptcha/model.php');
    $flag = false;
    //проверяем, есть ли в таблице запись для заданного номера и идентификатора формы
    $data = $sms->getData('+'.$rawPhone,$formid);
    if ($data->getID()) {
        //если есть и код не истек
	if ($sms->get('expires') > time()) {
            //смотрим, использован ли код
            if ($sms->get('active')) {
		$FormLister->addMessage('Вы уже использовали код.');
	    } else {
		$FormLister->addMessage('Код уже был отправлен. Подождите несколько минут прежде чем запросить новый.');
	    }
        //если код истек, то удаляем запись и разрешаем выдать новый
	} else {
	    $sms->delete($sms->getID());
            $flag = true;
	}
    } else {
	$flag = true;
    }
    //если можно выдать новый код
    if ($flag) {
	$code = mt_rand(1000,9999);
        
        //здесь отправляется смс и результат помещается в переменную $result
	$result = array('status'=>true);
	
        //проверяем отправлена ли смс
	if (is_array($result) && $result['status']) {
            //создаем запись в таблице, время жизни кода - 3 минуты
	    $result = $sms->create()->fromArray(array(
		'phone'=>('+'.$rawPhone),
		'formid'=>$formid,
		'expires'=>(time() + 60*3),
		'ip'=> \APIhelpers::getUserIP(),
		'code'=>$code
	    ))->save(); 
            //если получилось записать, то сохраняем в сессию номер телефона
        if ($result) {
	    $_SESSION[$session_key] = '+'.$rawPhone;
	} else {
            $FormLister->setValid(false);
	    $FormLister->addMessage('Не удалось отправить смс');
	}
    } else {
        //если нельзя выдать код, то запрещаем дальнейшую обработку формы
        $FormLister->setValid(false);
    }
}
?>


Ну и чтобы проверить, насколько удался рефакторинг я сделал поддержку рекапчи:

<script src='https://www.google.com/recaptcha/api.js'></script>
[!FormLister?
&formid=`basic`
&config=`default:core`
&rules=`{
"name":{
	"required":"Обязательно введите имя"
},
"email":{
	"required":"Обязательно введите email",
	"email":"Введите email правильно"
},
"phone":{
	"required":"Обязательно введите номер телефона",
	"phone":"Введите номер правильно"
},
"message":{
	"required":"Обязательно введите сообщение"
}
}`
&captcha=`reCaptcha`
&captchaParams=`{
"siteKey":"AAAAAK3E2RHXsUzMrir_rbEUAQ50mgCZ",
"secretKey":"AAAAA7q-vA1K6pkmwRmFDCnis6vaRuq",
"theme":"light"
}`
&captchaField=`g-recaptcha-response`
&formTpl=`@CODE:
<div class="row">
	<div class="col-md-8 col-md-offset-2">
		<div class="well">
			<form class="form-horizontal" method="post">
				<input type="hidden" name="formid" value="basic">
				<div class="form-group [+name.errorClass+][+name.requiredClass+]">
					<label for="name" class="col-sm-2 control-label">* Имя</label>
					<div class="col-sm-10">
						<input type="text" class="form-control" id="name" placeholder="Имя" name="name" value="[+name.value+]">
						[+name.error+]
					</div>
				</div>
				<div class="form-group [+email.errorClass+][+email.requiredClass+]">
					<label for="email" class="col-sm-2 control-label">* Email</label>
					<div class="col-sm-10">
						<input type="text" class="form-control" id="email" placeholder="Email" name="email" value="[+email.value+]">
						[+email.error+]
					</div>
				</div>
				<div class="form-group [+phone.errorClass+][+phone.requiredClass+]">
					<label for="phone" class="col-sm-2 control-label">* Телефон</label>
					<div class="col-sm-10">
						<input type="text" class="form-control" id="phone" placeholder="+375 29 123 45 67" name="phone" value="[+phone.value+]">
						[+phone.error+]
					</div>
				</div>

				<div class="form-group [+message.errorClass+][+message.requiredClass+]">
					<label for="message" class="col-sm-2 control-label">* Сообщение</label>
					<div class="col-sm-10">
						<textarea class="form-control" id="message" placeholder="Ваше сообщение" name="message" rows="10">[+message.value+]</textarea>
						[+message.error+]
					</div>
				</div>
				<div class="form-group">
					<div class="col-sm-10 col-sm-offset-2">
						[+captcha+]
						
					</div>
					<div class="col-sm-10 col-sm-offset-2">
						[+g-recaptcha-response.error+]
					</div>
				</div>
				[+form.messages+]
				<div class="form-group">
					<div class="col-sm-offset-2 col-sm-10">
						<button type="submit" class="btn btn-primary"><i class="glyphicon glyphicon-envelope"></i> Отправить</button>
					</div>
				</div>
			</form>
		</div>
	</div>
</div>`
!]


Документацию я не обновлял, так что пока не обновлю, параметры придется смотреть в коде (: Если вдруг кто-то захочет обновить сниппет, то обязателен бэкап, потому что какие-то параметры поменялись и все может сломаться. Баги тоже возможны — я тестировал только те изменения, которые понадобились для текущего проекта.

P.S. голосование накручивать перестали, количество голосов снизилось в 20 раз. Логи показали, что часть кодов не была использована, также некоторые голосующие пожаловались, что не смогли получить смс вовремя. Я списал это на рукожопость голосующих, однако причина серьезнее, следует это учитывать при использовании смс на сайте.

20 комментариев

avatar
Любопытно, а то, что неиспользованные плейсхолдеры не вырезаются — это моя частная проблема или так оно и надо?
Поставил самые свежие DocLister и FormLister. Параметры описаны в файле и вызываются через &config. Шаблон формы в чанке. Форма на сайте отправляется аяксом. В ответ приходит форма, но незадействованные плейсхолдеры отображаются как есть. В данном случае, для теста, в настройках телефон — необязателоное поле, поэтому и required вместо [+phone.requiredClass+] не проставился.


И, например, если имя указано, то на месте сообщения об ошибке выводится [+name.error+]. Так со всеми полями.

Аякс-запрос происходит к файлу начинающемуся так:
<?php
define('MODX_API_MODE', true);
include_once(dirname(dirname(__FILE__))."/index.php");
$modx->db->connect();
if (empty ($modx->config)) {
  $modx->getSettings();
}


Может я чего-то не понял или не включил?
avatar
В обычных условиях их вырезает парсер. Нужно подумать, как эту проблему лучше решить.
avatar
Использовал FormLister 1.0.0 по такой же схеме и там всё обрабатывалось нормлаьно. Плейсхолдеры не оставались. Ну и DocLister был тоже того времени, где-то за июнь 2016.
Может за это время вносились правки по обработке шаблонов?
avatar
Вносились, нужно смотреть DLTemplate.
avatar
Интересно, неужели вы ни одной формы аяксом не отправили после выхода новой версии сниппета? =)
Просто я думаю, может я что сейчас упустил.
avatar
Я чаще отправляю формы через документ с шаблоном _blank (:
avatar
Пока я понял лишь то, что после отправки форма не проходит вот эту проверку, поэтому и не выполняется cleanPHx.
avatar
Можно вынести cleanPHx из условия, тогда плейсхолдеры будут вырезаться.
avatar
Добавил параметр removeEmptyPlaceholders (по умолчанию 0), чтобы вырезать лишние плейсхолдеры.
avatar
Очень спасибо вам за отзывчивость и оперативность.
avatar
[+form.messages+] выводится пустым. Что через аякс, что не через аякс. В частности, должно было вывестись сообщение о том, что мол нет нужды отправлять данные еще раз.
avatar
А вызов какой?
avatar
[!FormLister?
&formid=`form1`
&rules=`{
"phone":{
"required":"Обязательно введите номер телефона"
}
}`
&formTpl=`@CODE:
<form class="contact-form" method="post" id="form1">
	<input type="hidden" name="formid" value="form1">
	<div class="row">
		<div class="col-sm-8">
			[+form.messages+]
			<div class="btn-group form-group btn-group-justified" data-toggle="buttons">
				<label class="btn btn-primary disabled">
					<input type="radio" name="office" id="option0" autocomplete="off" value="[+office.value+]">Офис:
				</label>
				<label class="btn btn-primary active">
					<input type="radio" name="office" id="option1" autocomplete="off" checked value="[+office.value+]"> Саратов
				</label>
				<label class="btn btn-primary">
					<input type="radio" name="office" id="option2" autocomplete="off" value="[+office.value+]"> Энгельс
				</label>

			</div>
			<div class="form-group">
				<input type="text" name="name" class="input-text full-width input-text2" placeholder="Ваше имя" value="[+name.value+]">
			</div>
			<div class="form-group">	
				[+phone.error+]<input type="text" name="phone" class="input-text full-width input-text2 phone" placeholder="+7 (___) ___-____" value="[+phone.value+]">
			</div>
		</div>
		<div class="col-sm-4 text-center">
			<button type="submit" class="btnForm"><img  src="assets/img/btn.png" alt=""/></button>
		</div>
	</div>
</form>
`
&errorTpl=`@CODE:<div class="alert alert-error" role="alert">[+message+]</div>`
&messagesTpl=`@CODE:<div class="alert alert-notice" role="alert">[+message+]</div>`
&errorClass=`has-error`
&requiredClass=`has-warning`
&to=`***@yandex.ru`
&subject=`Заказ Акция 5 в 1`
&reportTpl=`@CODE:
<p>Имя: [+name.value+]</p>
<p>Телефон: [+phone.value+]</p>
<p>Офис: [+office.value+]</p>
`
&successTpl=`@CODE:<p class="centered-text">Спасибо! Мы свяжемся с Вами в ближайшее время!</p>`
!]	
avatar
messagesTpl

Шаблон сообщений обработчика формы. В шаблоне выводятся группы сообщений (messages, required, error).

Возможные значения — имя шаблона, указанное по правилам задания шаблонов в DocLister.

Значение по умолчанию:

@CODE:<div class="form-messages">[+messages+]</div>

avatar
А вот так можно сделать вывод в стиле eForm:

&messagesTpl=`@CODE:<div class="form-group"><div class="col-sm-offset-2 col-sm-10">[+required+][+errors+][+messages+]</div></div>`
&messagesRequiredOuterTpl=`@CODE:<div class="panel panel-warning"><div class="panel-heading"><i class="glyphicon glyphicon-alert"></i> Вы не заполнили обязательные поля</div><div class="panel-body">[+messages+]</div></div>`
&messagesErrorOuterTpl=`@CODE:<div class="panel panel-danger"><div class="panel-heading"><i class="glyphicon glyphicon-alert"></i> Некоторые поля заполнены неверно</div><div class="panel-body">[+messages+]</div></div>`
&messagesOuterTpl=`@CODE:<div class="panel panel-default"><div class="panel-heading"></div><div class="panel-body">[+messages+]</div></div>`
avatar
[+messages+] — я упустил s в конце
avatar
История повторяется. Похоже сниппет не умеет подставлять в свои шаблоны переменные modx-шаблона типа [*id*] и ему подобные.
Или опять это только мне такое понадобилось?
avatar
Через параметр defaultsSources можно.
avatar
можно ли использовать «скрытую» reCaptcha? developers.google.com/recaptcha/docs/invisible
* заранее благодарю
avatar
Не знаю, не пробовал. Способ с кнопкой наверное сработает, а для второго способа нужно чуть-чуть доработать.
Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.