Глава 64 · Проект
Самостоятельный проект: форма обращения от GET до базы
Рабочая форма с валидацией, CSRF, SQLite, защитой от дублирования и понятными сообщениями.
От одной проверки к работающей форме: что запускать и какие файлы читать
Задача проекта — принять обращение посетителя и сохранить его один раз. Раньше небольшая страница формы только показывала результат проверки; здесь к ней добавлены постоянное хранение и защита от повторной отправки. Начните с одной части: запустите настоящую проверку полей без браузера и базы. Так результат проверки можно увидеть до чтения большого HTTP-обработчика.
Нужная база: массив с именованными полями, вход и результат функции, подключение соседнего PHP-файла. Запись PracticalForm\Validation — полное имя готового класса; пространство имён отличает его от чужих Validation. Писать новый класс в этом опыте не требуется.
- Получить массив name, email, message
- Validation::contact возвращает values и errors
- Выбрать одну ветку: есть ошибки → форма; ошибок нет → хранилище
| Запись | Что выполняется |
|---|---|
| require __DIR__ . '/Validation.php'; | Подключает приложенный файл. Определение класса становится доступно, но проверка ещё не вызвана. |
| PracticalForm\Validation::contact($submitted) | Вызывает готовый статический метод contact с одним массивом. :: обращается к классу без new; сам метод читает переданные данные, а не $_POST. |
| $checked['values'] | Массив нормализованных строк: например, внешние пробелы удалены через trim. |
| $checked['errors'] | Массив ошибок по именам полей. Пустой массив означает, что проверки этой функции прошли. |
<?php
declare(strict_types=1);
require __DIR__ . '/Validation.php';
$submitted = [
'name' => ' Анна ',
'email' => ' reader@example.test ',
'message' => ' Нужна помощь с заказом. ',
];
$checked = PracticalForm\Validation::contact($submitted);
echo 'Ошибок: ', count($checked['errors']), "\n";
echo 'Имя: ', $checked['values']['name'], "\n";
echo 'Email: ', $checked['values']['email'], "\n";
echo 'Сообщение: ', $checked['values']['message'], "\n";
Ожидаемый результат
Ошибок: 0
Имя: Анна
Email: reader@example.test
Сообщение: Нужна помощь с заказом.
Распакуйте example.php и Validation.php рядом, проверьте наличие mbstring командой php -m и выполните php example.php. Это тот же Validation.php, который лежит в src полного проекта. Здесь явно задан учебный снимок полей; сессия, HTTP и база не запускаются. Две пары квадратных скобок выбирают сначала карту values, затем поле внутри неё.
Измените одно поле. Поставьте message равным «Коротко». Число ошибок станет 1: сообщение короче десяти символов. Чтобы увидеть текст, выведите $checked['errors']['message']. Остальные проверенные значения останутся доступны для повторного показа. В настоящем обработчике наличие ошибок не должно вести к сохранению.
Теперь скачайте полный проект формы, распакуйте весь архив и запустите команду ниже из его корня: php -S 127.0.0.1:8770 -t public. Для проекта нужны mbstring и pdo_sqlite; для первого опыта выше достаточно mbstring. Откройте http://127.0.0.1:8770/. Остановка сервера — Ctrl+C. Папка public является доступной браузеру частью; src, templates и var остаются за её пределами.
| Действие читателя | Что происходит в реальном проекте | Где смотреть |
|---|---|---|
| Открыть адрес в браузере | GET подготавливает сессию, пустые поля и токены; затем показывает HTML. | public/index.php → templates/form.php |
| Отправить неверный email | POST проверяет запрос и токены, затем поля. Ошибка даёт 422 и ту же форму с сообщением. | public/index.php → src/Validation.php → templates/form.php |
| Отправить Анна / reader@example.test / Нужна помощь с заказом. | После успешных проверок обращение сохраняется в SQLite. Ответ 303 направляет браузер на /?sent=1. | src/ContactStore.php → public/index.php |
| Посмотреть результат и обновить страницу | Новый GET показывает одноразовое сообщение из сессии. Обновление этого GET не повторяет POST; сообщение после прочтения исчезает. | public/index.php → templates/form.php |
Форма отправляет application/x-www-form-urlencoded и получает HTML либо перенаправление. Этот проект не принимает JSON-запрос вместо формы: такой формат получает 415. SQLite хранит запись в var/contacts.sqlite; JSON здесь не является ни HTTP-ответом, ни форматом файла базы. В ContactStore JSON используется внутри вычисления отпечатка нормализованных полей — это отдельная внутренняя задача.
Читайте проект по пути запроса, а не пытаясь запомнить все файлы сразу. Сначала найдите в шаблоне name="email", затем чтение $_POST и вызов Validation::contact в index.php, затем развилку errors и вызов save. Разделы ниже объяснят CSRF, ключ операции, транзакцию и повтор. Для одного только разбора проверки полей эти механизмы пока можно изучать по одному.
Запуск и устройство проекта
Как обращение проходит от формы до записи в SQLite
Учебная форма должна сохранить заявку один раз, объяснить ошибки и пережить обновление страницы после успеха.
- GET: показать форму
Сервер выдаёт поля, CSRF-токен и ключ операции. У этих двух значений разные задачи.
- POST: принять и проверить
Проверяем запрос, CSRF, типы и правила полей. При ошибках возвращаем форму без сохранения.
- PDO: сохранить или узнать повтор
SQLite хранит заявку с ключом операции. Тот же ключ не должен создавать вторую запись.
- 303: перейти к результату
После успешного сохранения браузер получает адрес страницы результата и делает GET.
- GET: показать подтверждение
Обновление этой страницы повторяет чтение, а не исходный POST с заявкой.
CSRF защищает от подделки запроса, ключ операции — от дубля. Повтор того же ключа с другими данными должен дать конфликт.
Соберём форму обращения для небольшого сайта. Посетитель вводит имя, email и сообщение, сервер проверяет данные и сохраняет обращение в SQLite. Это законченный учебный проект: форма работает после распаковки, а база создаётся вне публичного каталога. Он показывает несколько независимых механизмов в одном запросе и позволяет увидеть, в какой момент каждый нужен.
Используйте вымышленные данные. Пример не отправляет письма, не подключается к платёжным системам и не публикует список обращений. Его можно исследовать без внешних учётных записей. Политика полей намеренно конкретна: имя 2–80 символов, ASCII-email до 254 байт и сообщение 10–2000 символов. В своём продукте сначала согласуйте этот договор: технически корректный email не подтверждает владение ящиком, а международный адрес может требовать другой политики.
- GET: сессия и форма
- POST: граница HTTP
- CSRF и ключ операции
- Типы и правила полей
- Транзакция SQLite
- 303 → GET результата
php -m
php -S 127.0.0.1:8770 -t public
Команды выполняются из каталога проекта. Нужны PHP 8.2+, mbstring и pdo_sqlite. Откройте http://127.0.0.1:8770/. Сервер слушает только локальный интерфейс; для публичной эксплуатации встроенный сервер не предназначен.
| Файл | Ответственность |
|---|---|
| public/index.php | HTTP-метод, лимиты, сессия, CSRF, выбор ответа и PRG. |
| src/Validation.php | Проверка входных значений; не читает $_POST и не отправляет заголовки. |
| src/ContactStore.php | Подготовленные SQL-запросы, транзакция, уникальный ключ операции. |
| templates/form.php | HTML5 и Bootstrap 5, подписи, сообщения ошибок и экранирование. |
| var/contacts.sqlite | Данные вне document root; создаются локально и не входят в архив. |
Три разные проверки: запрос, CSRF и ключ операции
Сначала сервер проверяет метод: чтение формы разрешено через GET, изменение — через POST, остальные методы получают 405 с Allow. Затем проверяется тип тела и размер. Content-Length недостаточно: он может отсутствовать или не совпадать с фактическим телом. Пример читает не более лимита плюс один байт. В рабочем окружении первый лимит должен стоять ещё на веб-сервере: PHP получает уже принятый запрос, а POST-парсер имеет собственные ограничения post_max_size и max_input_vars.
После запуска сессии создаётся случайный CSRF-токен. При POST он должен быть строкой ожидаемой длины и совпасть с токеном сессии через hash_equals. До сравнения нельзя вслепую приводить массив к строке. SameSite=Lax и HttpOnly полезны, но это не замена токену и не защита от XSS на вашем домене. Флаг Secure зависит от настоящего HTTPS. За обратным прокси нужен доверенный источник информации о TLS; принимать произвольный X-Forwarded-Proto от клиента нельзя.
Ключ операции решает другую задачу. Он связывает повтор запроса с одним сохранением. Сервер выдаёт его заранее и держит до десяти ключей с часовым сроком действия; старые вкладки могут получить понятную ошибку 409. Это выбранный для примера компромисс между несколькими вкладками и ограниченным объёмом сессии. В магазине срок и правила повторов нужно согласовывать с жизнью заказа и платёжной операции.
Проверка полей и безопасный повторный показ
Проверка вынесена в отдельный класс, чтобы её можно было запускать без браузера и сессии. Для каждого поля сначала проверяются тип и UTF-8, затем ограничивается сырой размер, потом выполняется trim и проверяется длина. Значения и ошибки возвращаются отдельно. Неправильное поле не превращается в успешное пустое значение, а корректные поля сохраняются для повторного показа.
<?php
declare(strict_types=1);
require 'Validation.php';
use PracticalForm\Validation;
$valid = ['name'=>'Анна', 'email'=>'reader@example.test', 'message'=>'Нужна помощь с заказом.'];
echo count(Validation::contact($valid)['errors']), "\n";
$wrong = $valid;
$wrong['name'] = ['Анна'];
$wrong['email'] = 'not-an-email';
echo implode(',', array_keys(Validation::contact($wrong)['errors'])), "\n";
Ожидаемый результат
0
name,email
Здесь воспроизводится реальный запрос name[]=Анна: PHP создаёт массив. Проверка типа предотвращает TypeError внутри trim и даёт ошибку именно этого поля. Код использует тот же Validation.php, что и готовый проект.
<?php
declare(strict_types=1);
require 'Validation.php';
use PracticalForm\Validation;
$result = Validation::contact([
'name'=>'<b>Анна</b>',
'email'=>'bad',
'message'=>'Хочу узнать о доставке.'
]);
echo htmlspecialchars($result['values']['name'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), "\n";
echo array_key_exists('email', $result['errors']) ? "email: ошибка\n" : "OK\n";
Ожидаемый результат
<b>Анна</b>
email: ошибка
Здесь намеренно разрешён текст с угловыми скобками: он экранируется в HTML-контексте. Это не универсальная очистка для JavaScript, URL или SQL. Правила имени в продукте можно сузить, но такой запрет сам по себе не заменяет безопасный вывод.
Сохранение и повтор операции
Хранилище получает уже проверенные значения и PDO. Оно не знает о $_POST: поэтому его можно использовать из HTTP, CLI или теста. Уникальный индекс operation_key защищает от второго сохранения того же ключа на уровне базы. Проверка «сначала SELECT, потом INSERT» без такого ограничения имела бы окно гонки. В примере SQLite используется INSERT … ON CONFLICT(operation_key) DO NOTHING, а затем читается существующая запись.
Ключ хранится вместе с SHA-256 нормализованного набора полей. Если тот же ключ пришёл с другим содержимым, система возвращает конфликт: нельзя молча показать успех для другого сообщения. Это хеш сравнения, а не шифрование и не способ скрыть персональные данные. Секретность ключа обеспечивается случайностью и проверкой принадлежности сессии. Для нескольких побочных эффектов, например письма и записи в базе, одного такого INSERT недостаточно: потребуются отдельные правила доставки, outbox и идемпотентный потребитель.
<?php
declare(strict_types=1);
require 'ContactStore.php';
use PracticalForm\ContactStore;
$pdo = new PDO('sqlite::memory:');
$store = new ContactStore($pdo);
$data = ['name'=>'Анна','email'=>'reader@example.test','message'=>'Нужна помощь с заказом.'];
echo $store->save('example-operation-1', $data), "\n";
echo $store->save('example-operation-1', $data), "\n";
$data['message'] = 'Изменённое сообщение';
try { $store->save('example-operation-1', $data); }
catch (DomainException) { echo "Конфликт\n"; }
echo $pdo->query('SELECT COUNT(*) FROM contacts')->fetchColumn(), "\n";
Ожидаемый результат
1
1
Конфликт
1
В тесте используется настоящая база в памяти, поэтому проверяется уникальность, SQL и откат, а не имитация метода save. Два одинаковых вызова возвращают один id. Последний вызов изменяет данные с прежним ключом и отклоняется; число записей остаётся равным одному.
Ошибки, PRG и доступность формы
При ошибках полей сервер отвечает 422 и снова показывает форму: значения экранированы, сообщение связано с полем через aria-describedby, неверное поле получает aria-invalid. Общий список ошибок содержит ссылки на соответствующие поля. Клиентская валидация в учебном проекте отключена атрибутом novalidate, чтобы можно было увидеть серверные ветки; в рабочей форме её можно добавить как удобство, сохранив проверки сервера.
После успешного сохранения сервер кладёт короткое сообщение результата в сессию, закрывает её через session_write_close и отдаёт 303 Location на фиксированный локальный адрес. Следующий запрос уже GET. В URL нет имени и email; обновление страницы результата не повторяет POST. Сообщение читается один раз, поэтому после обновления оно исчезает — это поведение flash, а не потеря записи. Для постоянного просмотра результата нужна защищённая страница с проверкой права доступа.
| Ответ | Что произошло | Как проверить |
|---|---|---|
| 200 | Получение формы или страницы результата | Откройте локальный адрес. |
| 422 | Ошибка полей | Отправьте короткий текст или неверный email. |
| 403 | Неверный CSRF | Подмените токен тестовым запросом. |
| 409 | Ключ устарел или повторён с другим содержимым | Повторите ключ, изменив сообщение. |
| 303 | Сохранение успешно | Посмотрите переход POST → GET. |
| 405 / 415 / 413 | Метод / формат / размер не поддерживается | Отправьте PUT, JSON или тело больше 32 КиБ. |
Упражнение: добавить номер заказа
Расширьте форму полем «номер заказа». Принимайте его как необязательную строку только из цифр заданной длины. Не преобразуйте значение с ведущими нулями в int, если это внешний идентификатор. Добавьте поле в договор, проверку, схему, запрос и HTML. Затем проверьте отсутствие, пустую строку, корректное значение и массив вместо строки.
Предлагаемое решение. Сначала определите нормальную форму: отсутствующий или пустой номер становится null, непустой обязан соответствовать заданному формату. Добавьте тесты валидатора до SQL. В хранилище включите номер в payload_hash, иначе повтор с другим номером ошибочно сочтётся одинаковым. Изменение схемы оформите миграцией; создание таблицы IF NOT EXISTS не добавит новую колонку в уже существующую базу.