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 раз. Логи показали, что часть кодов не была использована, также некоторые голосующие пожаловались, что не смогли получить смс вовремя. Я списал это на рукожопость голосующих, однако причина серьезнее, следует это учитывать при использовании смс на сайте.

44 комментария

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
Не знаю, не пробовал. Способ с кнопкой наверное сработает, а для второго способа нужно чуть-чуть доработать.
avatar
Попробовал наконец использовать и вот что выяснил.
Если использовать чанк для formTpl, то внутри него не срабатывают плейсхолдеры evoBabel [%слово%], хотя в обычном чанке DocLister всегда срабатывали.
Использовать @CODE тоже не могу, т.к. для валидации полей на js один из разрешенных символов — апостроф ` и от этого ломается верстка :) И как быть?
avatar
Нужен сайт с евобабелем для тестов, лексиконы из файлов или параметра работают нормально.
avatar
FormLister как-то самостоятельно парсит теги [%%]?
Скорее всего DocLister просто отдает их «как есть», а потом уже плагин от evoBabel их допереводит. А тут какая логика?:)
avatar
Такая же, как в DocLister (:

public function getMsg($name, $def = '')
    {
        $out = \APIhelpers::getkey($this->_lang, $name, $def);
        if (class_exists('evoBabel', false) && isset($this->modx->snippetCache['lang'])) {
            $msg = $this->modx->runSnippet('lang', array('a' => $name));
            if (!empty($msg)) {
                $out = $msg;
            }
        }

        return $out;
    }

Поставил я evoBabel, работает и с ним.
avatar
Странно, почему у меня съел. Пока было через @CODE работало, а засунул в чанк formTpl и все пропало. Сейчас еще поковыряюсь.
avatar
Я разве что не стал настраивать tv и все такое, поэтому в плагине переместил include_once MODX_BASE_PATH. 'assets/snippets/evoBabel/evoBabel.class.php'; из условия, а в сниппете lang добавил $id = 'ru'.
avatar
В общем, тут я разобрался в чем дело, у нас разные представления о плейсхолдерах ;))
Сравни со своим ;)

Но небольшая проблема осталась — в чанке formTpl почему-то не парсится у меня [*id*] и соответственно [~[*id*]~]
avatar
Сам спросил — сам ответил :)
&parseDocumentSource=`1`
avatar
У меня из доклистера регулярка.
avatar
Ну да, латиница без пробелов с нижним подчеркиванием. А в evoBabel можно и на русском и с пробелами задавать слова для подмены. Что-то типа [%Ваше имя%] — потому у меня ничего и не находило :)
avatar
Можно ли как-то стандартными средствами (через prepare) подсунуть для отправки файл?

Есть сбор заявок с формы, в prepareProcess эти заявки пишутся в БД. И на основе этих заполненных полей идет генерация файла pdf с именем вида «номер заявки — дата — время» (т.е. нет постоянного имени).

Так вот нужно этот файл прикрепить к текущему письму «ваша заявка принята».
avatar
Есть параметр для прикрепления произвольных файлов.
avatar
Я видел attachFiles, но что-то подумал, что из prepare конфиг не меняется, нашел только метод getCFGDef, а вот set… не видел. Или эти поля как и data через setField ставятся?
avatar
$FormLister->Config->setConfig(array());
avatar
Слишком глубоко:)
avatar
По мне так лучше не возиться с prepare, а расширять контроллер и там уже делать как угодно.
avatar
Все зависит от бюджетов, в данном случае хватит и prepare) В prepareProcess, насколько я понял, уже все проверки прошли и именно в нем можно уже писать результаты в базу? prepareAfter это уже после отправки и там формировать pdf уже поздно?) Сам pdf сделаю отдельным сниппетом, чтобы можно было и в других местах использовать. Передам в него $data, reportTpl аналогичный переданному в FormLister, да поле $action=FormLister, чтоб вернул массив нужной структуры. Вот такой вот план)
avatar
Да, prepareProcess выполняется после успешной валидации, перед выполнением основного действия. А prepareAfterProcess — когда основное действие выполнено.
avatar
Тут вот так работает
$FormLister->config->setConfig(array());
:)
Но, возникли очередные вопросы (по отправке «копии письма в поле email». Задал &ccSender=`1` и вот что получилось:
1. в этом письме, к сожалению, отвалился attach. Т.е. оригинальный attachment был по логу
Array
(
    [filepath] => /var/www/gospay/data/www/.../assets/files/zajavka/2018/01/16_22-12-04-07.pdf
    [filename] => 16_22-12-04-07.pdf
)


в копии этот аттач улетел в массив $data ( и от него осталось только имя)
[attachments] => Array
                (
                    [0] => 16_22-12-04-07.pdf
                )

2. ну и по мелочи :) Называется «копия письма» — но на самом деле требуется дополнительный чанк ccSenderTpl (без него не работает). Неплохо бы, чтобы при заданном ccSender=`1` и незаданном ccSenderTpl брался reportTpl (а то не сразу поймешь, почему письма не идет).
avatar
Ну и небольшой «баг» по «дебагу» — там хоть 0 ставь, хоть 1 — лог все-равно пишется. Не пишется только если вообще не задавать этот параметр :)
Наверно потому, что в условии
if (!is_null($this->debug)) {}
avatar
Этот баг исправлен.
avatar
Вложения только в основном письме работают.
avatar
Да, пришлось-таки расширить контроллер Form :)
avatar
Скажите, пожалуйста, как после нажатия на кнопку отправки формы оставаться на том же уровне прокрутки страницы. Как вариант, задать hash www.site.com/#contacts

Для случаем, когда контактная форма глубоко внизу страницы, а валидация возвращает наверх
Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.