Skip to content

Latest commit

 

History

History
executable file
·
291 lines (188 loc) · 26.3 KB

forms.md

File metadata and controls

executable file
·
291 lines (188 loc) · 26.3 KB

Алгоритм обработки данных форм

Форма - это набор полей на странице сайта, которые пользователь может заполнить и отправить на сервер.

При работе с формами надо на сервере проверять введенные значения на правильность (это называется валидация), а при ошибке — выводить ту же форму повторно, с уже подставленными значениями и сообщениями об ошибке. Чтобы пользователь мог исправить ошибки и повторить отправку, и не вводил бы все заново (исключение - введенные в форму пароли, их нельзя выводить в форме при ошибке или где-то еще).

Есть универсальный алгоритм, который позволяет это реализовать. Он работает с любыми формами, отправляемыми как HTTP-методом GET, так и POST. Он может быть реализован как с использованием ООП, так и без.

Чтобы это сделать, необходимо, чтобы выводил и принимал данные от формы один и тот же скрипт. К примеру, при заходе по адресу /register.php мы видим форму регистрации, заполняем ее и отправляем данные на тот же адрес.

На тот случай, если ты не изучал протокол HTTP (что плохо), я напомню, что есть 2 метода для отправки данных из форм: GET и POST. Используемый метод задается в атрибуте method HTML-тега <form> (а у POST форм есть 2 способа кодирования данных, которые выбираются через атрибут enctype: application/x-www-form-urlencoded и multipart/form-data). Вот их основные особенности:

  • GET должен использоваться только для форм, которые не меняют данные на сервере: форма поиска, форма перехода на определенную страницу. POST используется для любых форм (добавление, изменение, удаление информации)
  • файлы можно прикладывать только к POST-формам, у которых enctype равен multipart/form-data
  • GET-форма добавляет введенные данные в URL и потому пользователь может скопировать и сохранить или переслать ссылку на результаты (например на результат поиска). POST форма ничего не добавляет в URL, и при открытии этого URL мы увидим лишь пустую форму.
  • объем данных в GET-форме ограничен 500-2000 символов (так как браузеры и серверы ограничивают длину URL)

Вот сам алгоритм:

// $values - это массив или объект, хранит введенные в форму значения
$values = значения по умолчанию (пустые);
// $errors хранит список обнаруженных при проверке ошибок
$errors = пустой массив или объект;

Если (форма отправлена) {
    // Защита от уязвимости XSRF
    Проверяем, что запрос отправлен с нашего сайта, а не с другого. 
    Если с другого - то завершаем скрипт с ошибкой;

    Копируем переданные значения полей из $_GET или $_POST в $values;
    Проверяем значения в $values и записываем найденные ошибки в $errors;

    Если (ошибок нет) {
        Делаем требуемое действие (например вставляем запись в БД);
        Редиректим куда-нибудь;
        Завершаем скрипт;
    }
}

Выводим форму($values, $errors);

Давай разберем его подробно.

Вначале мы заводим 2 переменные: первая будет хранить введенные в форму значения, вторая — список найденных ошибок. $values мы заполняем значениями по умолчанию. Это могут быть как массивы, так и объекты.

Затем мы проверяем, отправлена ли форма. Если форма использует метод POST, то достаточно проверить хранящееся в $_SERVER['REQUEST_METHOD'] значение (каким методом была запрошена страница). Если форма использует метод GET, то можно проверять наличие какого-то параметра в массиве $_GET.

Если пользователь просто зашел на страницу, то мы выводим форму со значениями по умолчанию. Если же она отправлена, то первым делом мы делаем проверку, не отправлена ли форма со стороннего сайта для защиты от уязвимости XSRF. Если это так, то прекращаем обработку данных.

Если все ок, то мы переносим значения полей в $values. Это делается для того, чтобы при ошибке мы могли бы их вывести в форме. Копирование можно сделать отдельной функцией или методом.

При копировании значений стоит обрезать пробелы с краев с помощью функции trim(). Ведь человек может случайно ввести пробел и не заметит его, а для компьютера строки 'hello' и 'hello ' — различаются. Это не требуется делать для полей типа <select>, где значение не вводится вручную.

Также, стоит помнить, что в массивах, полученных от пользователя ($_COOKIE, $_POST, $_GET), любые элементы могут отсутствовать или содержать что угодно (массив вместо строки например). Вот такой код предусматривает безопасное получение переданных значений:

// Некоторые используют isset вместо array_key_exists
$name = array_key_exists('name', $_POST) ? strval($_POST['name']) : '';

Функция strval принудительно преобразует любые переданные данные в строку.

Затем, мы проверяем данные. Это тоже удобно вынести в функцию, которая, например, принимает на вход $values и возвращает $errors.

Если все данные введены правильно, то мы после их обработки делаем редирект на какую-то другую страницу, например на страницу просмотра введенной информации, страницу благодарности, и т.д. Редирект необходим, чтобы при обновлении страницы форма не отправлялась повторно (если ты не знаешь, что такое редирект, то это выдача HTTP-заголовка вроде Location: /thankyou.php. В php для этого используется функция header()).

Этот подход (редирект после успешной обработки формы) называется Post/Redirect/Get.

Код вывода формы, разумеется, стоит поместить в отдельный файл, ведь смешивать вместе PHP-логику и HTML — плохая идея.

Форма редактирования

Если ты делаешь форму редактирования информации (например, форма редактирования комментария), то можешь заметить, что код будет очень похож на форму добавления информации (например, форму добавления комментария). Для обоих этих форм можно использовать один код, добавив несколько изменений:

$values = значения по умолчанию (пустые);

Если (мы редактируем сущность, а не создаем новую) {
    $values = загруженные из БД значения;
}

...

    Если (ошибок нет) {
        Если (мы редактируем сущность) {
            Обновляем запись в БД;
        } иначе {
            Вставляем новую запись в БД;
        }

        Редиректим куда-нибудь;
        Завершаем скрипт;
    }

...

Как видишь, добавив пару ветвлений (если/иначе), мы смогли использовать один и тот же код для выполнения двух похожих задач.

ООП-подход

Если ты используешь ООП, то для хранения введенных данных ($values) удобно использовать объект, который соответствует редактируемой сущности. Например, если это форма регистрации, то объект User, хранящий информацию о пользователе.

Это позволяет отвязать функцию валидации от формы: она просто проверяет объект User, не зная, откуда он пришел - из формы, из БД, или откуда-то еще. Получается разделение ответственности.

Это, однако, работает только в простых случаях. Не всегда поля формы точно соответствуют полям сущности. Например: в форме регистрации мы имеем 2 поля для ввода пароля и подтверждения, но в таблице БД и в объекте User мы храним лишь соленый хеш от пароля. Есть разные решения:

  • хранить "лишние" поля формы где-то отдельно от объекта User (в массиве или переменных) и сделать отдельную функцию их проверки
  • сделать класс UserForm и хранить в нем и объект User, и лишние данные, там же можно их проверять и преобразовывать в нужный вид
  • сделать класс UserForm, хранить в нем массив данных формы и после проверки переносить их в объект User. Это не позволяет отделить валидацию от формы.

Ошибки можно хранить в массиве или сделать специальный объект вроде FormErrors. Хранить ошибки в том же объекте User будет неправильно, так как лучше, когда каждый класс занимается своим делом, и класс User только хранит информацию о пользователе и ничего не знает про форму редактирования.

По той же причине валидацию лучше делать в отдельном внешнем классе или функции, а не помещать внутрь редактируемого объекта. Но в маленьких приложениях можно делать и так.

Вот пример простого ООП-кода обработки формы редактирования/добавления статьи в блоге в контроллере. Здесь мы используем объект Post для представления статьи и PostValidator для проверки правильности введенных данных:

$postValidator = new PostValidator;
$postDbGateway = new PostDbGateway;
$post = new Post;

if (идет редактирование поста с номером $postId) {
    // загрузить $post из базы данных
    $post = $postdbGateway->findById($postId);
}

$errors = [];

if ($_SERVER['REQUEST_METHOD'] == 'POST') {
    if (isXsrfViolation(...)) {
        // Попытка эксплуатации XSRF - показать страницу ошибки и завершить скрипт;
        throw new \XsrfViolationException();
    }

    parseRequest($post, $_POST);
    $errors = $postValidator->validate($post);

    if (!$errors) {
        $postDbGateway->save($post);
        redirect('/success');
        return;
    }
}

// выводим форму

Иногда для упрощения в объекте Post делают метод для заполнения данных из произвольного массива и передают туда данные формы:

$post->fill($_POST);

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

Если у нас есть несоответствие полей между формой и редактируемым объектом, или просто хочется больше ООП, то можно попробовать сделать вспомогательный класс PostForm для хранения данных из формы:

...
$post = new Post;
if (идет редактирование поста с номером $postId) {
    загрузить $post из базы данных;
}

$postForm = new PostForm($post, $postValidator);
$errors = new FormErrors();

if (форма отправлена) {
    проверка на XSRF;

    $postForm->fill($_POST);

    // postForm проверяет "лишние" поля сам, а проверку 
    // хранящихся в Post полей делегирует валидатору
    $errors = $postForm->validate($post);

...

В больших фреймворках (Yii, Symfony 2) обычно есть стандартные классы для форм и для полей разных типов, которые реализуют описанный выше алгоритм. Ты только задаешь, какие должны быть поля у формы, какие к ним применяются ограничения, что делать в случае заполнения формы, а класс формы сам принимает, обрабатывает и проверяет введенные данные. Также фреймворки часто предоставляют защиту от уязвимости XSRF путем добавления к форме и проверки токена. Ты можешь описывать новые типы полей, создав для них классы, и новые правила валидации.

Сложный пример кода можно увидеть в документации по компоненту Symfony Forms (англ.): https://symfony.com/doc/current/forms.html

Отправка формы через AJAX

Данные формы можно отправлять через AJAX. Плюсы - нет перезагрузки страницы и нет проблем с полями файлов и паролей, минусы - требуется использование JS.

JS-код на клиенте должен собрать данные, отправить запрос на сервер, проверить ответ и при ошибке вывести сообщения об ошибках. А при успешной обработке формы - показать какой-то текст или перейти на другую страницу.

Интересно, что алгоритм выше легко доработать, чтобы он мог принимать данные и от отправленной обычным способом формы, и от отправленной через AJAX формы. Потребуется лишь немного исправить код, обрабатывающий успешное заполнение формы и ее вывод:

// При успехе
Если (форма отправлена аяксом) {
    Отправить JSON ответ об успешном заполнении формы;
} иначе {
    Редиректить куда-нибудь;
}

....

Если (форма отправлена аяксом) {
    Возвращаем список ошибок в формате JSON;
} иначе {
    Выводим форму с подставленными значениями и сообщениями об ошибках;
}

Другой вариант реализовать аякс-отправку данных почти без написания JS кода - использовать библиотеку вроде pjax.

При реализации AJAX не забудь про индикатор прогресса и обработку ошибок.

Поля с файлами и паролями

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

Пароли нельзя выводить из соображения безопасности. Потому в поля ввода пароля нельзя подставлять введенные значения, и пользователю при ошибке придется вводить пароль повторно. Это можно решить отправкой формы через AJAX, либо делать сложную систему с сохранением соленого хеша от пароля (нельзя сохранять пароль в открытом виде) во временное хранилище на сервере.

Безопасность

Вот какие ошибки можно сделать при обработке данных форм:

Логические ошибки.

Ты можешь случайно дать пользователям возможность редактировать те поля, которые они не должны редактировать. Ну например, в базе данных пользователей у тебя есть колонки name, email, is_admin. Разумеется, форма содержит только поля name и email. Но ведь злоумышленник может передать любые данные, не только те, для которых в форме есть поля. Если ты просто обновляешь в объекте-пользователе все те поля, которые переданы через _POST, не проверяя их:

foreach ($_POST as $key => $value) {
    $user->$key = $value;
}

$userMapper->save($user); // сохраняем данные из объекта в БД

то злоумышленник получит админские права, передав значение is_admin=1 вместе с формой. Чтобы этого не произошло, нужно обновлять только поля из разрешенного списка.

Именно из-за такой ошибки некто Егор Хомаков смог взломать сайт гитхаб (написанный на Ruby on Rails): http://www.opennet.ru/opennews/art.shtml?num=33268

Уязвимости

Форма может быть отправлена не только с твоего сайта, но и с любого другого, причем без ведома пользователя. Это называется уязвимость XSRF. Чтобы от нее защититься, нужно добавлять к форме и проверять специальный токен либо проверять HTTP-заголовки Referer/Origin. Об этом написано в отдельном уроке про XSRF.

Если ты выводишь данные в HTML-коде, не используя функцию экранирования спецсимволов вроде htmlspecialchars (или шаблонизатор, который это делает), то у тебя может быть уязвимость XSS, позволяющая злоумышленнику вставить произвольный скрипт в HTML-страницу, который выполнится в браузере пользователя. Прочти урок про XSS.

Очистка приходящих данных

Если поле может содержать ограниченный набор значений (например, только цифры или только значения 0 и 1), то можно при разборе данных из $_POST/$_GET/$_REQUEST/$_COOKIE блокировать все недопустимые значения. Это делает код чуть безопаснее.

Функции intval, floatval и strval преобразуют данные любого типа в целое, дробное число и строку соответственно. В примере ниже гарантируется, что $age может содержать только целое число:

$age = intval($_POST['age'] ?? '');

Оператор ?? (появился в PHP7) используется, чтобы при отсутствии ключа age в массиве $_POST не было ошибки. В PHP5 придется написать:

$age = intval(isset($_POST['age']) ? $_POST['age'] : 0);

Чтобы удалить лишние пробелы с помощью trim(), придется усложнить код. strval используется для защиты от ошибки, если пользователь передаст массив в поле age:

$age = intval(trim(strval($_POST['age'] ?? '')));

Если поле может содержать только значения вида "да"/"нет", его удобно преобразовать в тип bool:

$agreeToTerms = (($_POST['agree'] ?? '') == 'yes');

Если у поля может быть всего несколько значений, то можно проверить это:

$allowedColors = ['red', 'green', 'blue'];
$color = $_POST['color'] ?? '';
if (!in_array($color, $allowedColors)) {
    $color = $allowedColors[0];
}

Если в поле можно использовать только часть символов, можно вырезать лишние регуляркой:

$plateNumber = trim(strval($_POST['plate'] ?? ''));
$plateNumber = preg_replace("/[^a-zа-яё0-9]/ui", '', $plateNumber);

Наконец, в PHP есть встроенная функция filter_var, умеющая пропускать только значения определенных типов.