MODX. Формы связи Formit + AjaxForm. Защита форм.

Оглавление

Для реализации форм связи на сайтах под управлением Modx Revo мы всегда используем комбинацию двух пакетов - docs.modx.com/3.x/ru/extras/formit и docs.modx.pro/komponentyi/ajaxform для того что бы интегрировать данные в CMS

[[!AjaxForm?
    &snippet=`Formit`
    &form=`callback.tpl`
    &hooks=`spam,csrfhelper_formit,email,FormItSaveForm`
    &csrfKey=`callback`
    &emailTpl=`callback.email`
    &emailTo=`[[++emailsender]]`
    &emailFrom=`noreply@[[#SERVER.SERVER_NAME]]`
    &emailFromName=`[[++site_name]]`
    &emailSubject=`[ЗАЯВКА] Обратный звонок`
    &formFields=`name,phone,message,pagetitle,link,referrer`
    &fieldNames=`name==Имя,phone==Телефон,message==Сообщение,pagetitle==Страница,link==Ссылка,referrer==Реферрер`
    &formName=`[ЗАЯВКА] Обратный звонок`
    &validationErrorMessage=`В форме содержатся ошибки!`
    &successMessage=`Сообщение успешно отправлено`
    &validate=`mail:blank,jsready:contains=^jsready^`
]]
<form action="" method="post" autocomplete="disable" enctype="multipart/form-data" class="formWatcher">
    <div class="fields flex flex-column gap10">
        <div class="line">
            <div class="field">
                <input autocomplete="disable" type="text" placeholder="Ваше имя *" name="name" pattern="^[A-Za-zА-Яа-яЁё-]+$" title="Введите имя (только буквы)" required>
            </div>
            <span class="error">[[+fi.error.name]]</span>
        </div>
        <div class="line">
            <div class="field">
                <input autocomplete="disable" type="text" name="email" placeholder="Email *" required>
            </div>
            <span class="error">[[+fi.error.email]]</span>
        </div>
        <div class="line">
            <div class="field">
            	<input autocomplete="disable" type="tel" maxlength="16" name="phone" placeholder="+7 (000) 000 00 00" inputmode="tel" pattern="^\(?\+?[\d\(\-\s\)]+$" title="Введите телефон в формате +7 (000) 000 00 00" required>
            </div>
            <span class="error">[[+fi.error.phone]]</span>
        </div>
        <div class="line">
            <div class="field">
                <textarea name="message" rows="3" placeholder="Сообщение"></textarea>
            </div>
            <span class="error">[[+fi.error.message]]</span>
        </div>
        <div class="input-file">
            <label class="input-file__label">
                <input hidden type="file" name="uploads[]" data-id="input-file">
                <p class="input-file__title">Прикрепить файлы</p>
                <p class="input-file__file-extencion">В формате: .jpg, .png, .jpeg, .webp, .doc, .docx, .pdf, .excel, .txt</p>
                <p class="input-file__desc">Выберите или перетащите файлы</p>
            </label>
            <div class="input-file__info">
                <div class="input-file__success">
                    <p>Загружено файлов:
                        <span data-id="files-count">0</span>
                    </p>
                </div>
                <div class="input-file__files"></div>
            </div>
            <span class="error error_uploads">[[+fi.error.uploads]]</span>
        </div>
    </div>
    <div class="private-policy">Согласие на обработку <a href="[[~6]]" target="_blank">персональных данных</a>.</div>
    <div class="submit">
        <button type="submit" class="btn flex flex-items-center gap5">Отправить</button>
    </div>
    <input type="text" name="mail" class="d-none">
    <input type="text" name="jsready" class="d-none jsready">
    <input type="hidden" name="pagetitle" value="[[*pagetitle]] (id: [[*id]])">
    <input type="hidden" name="link" value="[[~[[*id]]]]">
    <input type="hidden" name="ip" value="[[#SERVER.REMOTE_ADDR]]">
    <input type="hidden" name="referrer" value="[[#SERVER.HTTP_REFERER]]"> 
    <input type="hidden" name="agent" value="[[#SERVER.HTTP_USER_AGENT]]">
    <input type="hidden" name="csrf_token" value="[[&excl;csrfhelper? &key=`callback`]]">
</form>
<b>Форма:</b> [[+formName]]<br/>
<b>Имя:</b> [[+name]]<br/>
<b>Email:</b> [[+email]]<br/>
<b>Телефон:</b> [[+phone]]<br/>
<b>Сообщение:</b> [[+message]]<br/>
<hr>
<b>Страница:</b> [[+pagetitle]]<br/>
<b>Ссылка:</b> [[++site_url]][[+link]]<br/>
<b>Реферрер:</b> [[+referrer]]<br/>
<b>IP:</b> [[+ip]]<br/>
<b>Браузер:</b> [[+agent]]<br/>

Для отправки формы с вложениями необходимо указать для неё метод кодирования данных enctype="multipart/form-data"

Добавляем поле в чанк формы

<div class="form-group">
    <label for="files">Прикрепить файлы</label>
    <input type="file" name="files[]" multiple="multiple">
    <p class="error_files">[[+fi.error.files]]</p>
</div>     

Создадим сниппет formit2checkfiles

<?php
// инициализируем переменную output, отвечающую за результат работы хука, со значением true
$output = true;
// разрешённые расширения файлов
$allowedExt = array('jpg','png','jpeg','webp','doc','docx','pdf','excel','txt');
// максимальный размер файла 10мб
$maxFileSize = 10485760;
// если ассоциативный массив $_FILES[$keys] существует, то
if(isset($_FILES[$key]["error"])) {
  // переберём все файлы (изображения)
  foreach ($_FILES[$key]["error"] as $fkey => $error) {
    // если ошибок не возникло, т.е. файл был успешно загружен на сервер, то...
    if ($error == UPLOAD_ERR_OK) {
      // имя файла
      $fileName = basename($_FILES[$key]['name'][$fkey]);
      // расширение файла
      $fileExt = mb_strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
      // размер файла
      $fileSize = filesize($_FILES[$key]['tmp_name'][$fkey]);
      // проверка расширения файла
      if(!in_array($fileExt, $allowedExt)) {
        // файл имеет недопустимый тип
        $errorMsg = 'Файл ' . $fileName . ' имеет не разрешённый тип.';
        $validator->addError($key, $errorMsg);
        $output = false; // возвращаем false
        break;
      }
      if($fileSize > $maxFileSize) {
        // файл имеет размер больше максимального
        $errorMsg = 'Файл '. $fileName .' имеет не разрешённый размер.';
        $validator->addError($key,$errorMsg);
        $output = false; // возвращаем false
        break;
      }
    } else {
      // произошла ошибка при загрузке файла на сервер
      $errorMsg = 'Произошла ошибка при загрузке файла ' . $fileName .' на сервер.';
      $validator->addError($key,$errorMsg);
      $output = false; // возвращаем false
      break;
    }
  }
}
return $output; 
?>

В вызов формы добавляем валидацию

&customValidators=`formit2checkfiles`
&validate=`files:formit2checkfiles`

Существуют JS callback'и которые мы можем использовать, например наиболее востребованный:

/*After send forms*/
$(document).on("af_complete", function(event, response) {
    let form = response.form;
    if (form.attr('id') === 'discount-form-form') {
        form.hide();
        $('.discount-form-success').show();
    }
    if (form.attr('id') === 'callback-form') {
        form.hide();
        $('.callback-form-success').show();
        setTimeout(function(){
            $('#callback .is-close').trigger('click');
        }, 3000);
    }
});
/***--- END ---***/    

При обновлении пакета или его переустановке фикс нужно вносить заново!

Начиная с мая 2024 года начали поступать заявки о спаме, стандартная защита от которого не помогает, оказалось что эксплуатировалась проблема в сессиях AjaxForm. Прежде чем создавать формы мы должна закрыть эту дыру:

  • Скорректировать код в компоненте AjaxForms в файле "core/components/ajaxform/model/ajaxform/ajaxform.class.php":
    В объявлении метода process (~ 102 строка) добавить в вызов метода handleFormIt переменную $action (~ 131 строка), что бы получилось:
    $response = $this->handleFormIt($scriptProperties, $action);

    Далее добавить в объявлении метода handleFormIt (~ 149 строка) добавить входной параметр $action:
    public function handleFormIt(array $scriptProperties = array(), string $action = '')

    В этом же методе в условии обработки успешного срабатывания(~ 175 строка) вписать удаление сессии для текущего значение $action(~ 179 строка):
    } else {
        $message = isset($this->modx->placeholders[$plPrefix . 'successMessage'])
        ? $this->modx->placeholders[$plPrefix . 'successMessage']
        : 'af_success_submit';
        //начало вставки
        if (!empty($_POST)) {
            unset($_SESSION['AjaxForm'][$action]);
        }
        //конец вставки
        $status = 'success';
    }    
    

    Или скачать исправленный файл если ваша версия AjaxForm 4.2.7-pl (последняя на момент написания статьи) 

    ajaxform.class.php
  • Создаем в корне сайта файл например clear.php
    <?php
    define('MODX_API_MODE', true);
    header('Content-type: text/html; charset=utf-8');
    header('Cache-Control: no-store, no-cache, must-revalidate');
    header('Cache-Control: post-check=0, pre-check=0', false);
    require $_SERVER['DOCUMENT_ROOT'] . '/index.php';
    $modx->getService('error', 'error.modError');
    $modx->setLogLevel(modX::LOG_LEVEL_INFO);
    $modx->setLogTarget(XPDO_CLI_MODE ? 'ECHO' : 'HTML');
    unset($_SESSION['AjaxForm'])
    ?>
    
  • Обращаемся к файлу что бы очистить сессии, после этого файл можно удалять.

Мы будем комбинировать несколько разных методов, которые в сумме дают надежную защиту.

  • Устанавливаем плагин extras.modx.com/package/csrfhelper
    Во всех формах Formit дополняем сниппет вызова форм и чанк форм.
    Для каждой формы используем уникальный key при вызове
    <!--Например в вызове-->
    &hooks=`spam,csrfhelper_formit,email,FormItSaveForm`
    &csrfKey=`callback`
    <!--Внутри формы -->
    <input type="hidden" name="csrf_token" value="[[!csrfhelper? &key=`callback`]]">
    
  • Добавляем в параметр вызова формы &validate следующее:
    &validate=`mail:blank,jsready:contains=^jsready^` 
    
  • В чанк формы добавляем input именно с таким типом и без линейных стилей
    <input type="text" name="mail" class="d-none">
    <input type="text" name="jsready" class="d-none jsready">    
    
    Класс d-none в данном случае отвечает за стили display: none;
  • В ваши скрипты добавляем следующий скрипт:
    $('form').each(function() {
        let nameField = $(this).find('input[name="jsready"]');
        if (nameField.length > 0) {
          $(nameField).val('jsready')
        }
    })    
    

Основные причины это - оптимизация, сложность и время интеграции.

В современном вебе формы должны быть “юзер френдли”, если пользователь встретит на пути к конверсии препятствие которое его раздражает, он может бросить форму

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

Если говорить о скрытых капчах, таких как например reCaptcha v3, то их алгоритм работы не идеален и проблема не в том, что они иногда пропускают спам, проблема гораздо страшнее - они иногда блокируют реальных пользователей!

Это было выяснено путём подключения собственных логов к формам и сравнения реальных отправленных данных и тех что дошли в итоге до отправки, есть серьезные расхождения, а это значит мы теряем клиентов как и в случае видимых защит!

Так же не стоит забывать про техническую сторону вопроса, подключение таких защит связано с получением ключей и другими весьма времязатратными и сложными операциями (особенно когда нужно грузить несколько защит на одной странице), а самое главное - они очень больно бьют по скорости и производительности. Как правило на сайте используется не одна и не две формы, иногда их количество может приближаться к двадцати. И для каждой формы на странице будет подгружаться капча, будет происходить множество реквестов к внешним серверам, каждый из которых занимает милисекунды, но в сумме они и размер скаченных файлов будут отнимать по пол секунды загрузки страницы, что в условиях современного веба крайне много!

Т.к официальная поддержка AjaxForm прекращена есть смысл присмотреться к альтернативным пакетам которые имеют поддержку:

modstore.pro/packages/users/sendit

modstore.pro/packages/utilities/fetchit