Статический сайт прекрасно открывается без приложения на сервере: nginx отдаёт готовые файлы, браузер их рисует, и ломаться почти нечему. Идиллия длится ровно до того момента, когда посетителю нужно отправить заявку с приложенным техзаданием и получить подтверждение. Здесь появляются состояние, чужие файлы, почта, повторные нажатия и спам. Всё это статика сама по себе не умеет. Но из этого не следует, что нужно переезжать на CMS или поднимать кластер. Нужен маленький backend, который отвечает ровно за одну операцию: принять заявку и не потерять её.

Если сам сайт у вас собирается генератором, например Jekyll, и вы ещё не думали о его размещении, начните с материала Jekyll: статический сайт на VPS. Дальше речь только о форме и о том, что стоит за кнопкой «Отправить».

Путь одной заявки: от кнопки до записи

Проследим маршрут простым языком. Браузер отправляет POST-запрос на адрес вроде /api/requests. Nginx, который уже раздаёт статику, проксирует этот адрес на небольшой процесс приложения на том же сервере. Приложение проверяет поля, сохраняет заявку и только после этого отвечает «принято». Всё остальное — письмо менеджеру, сообщение в мессенджер, запись в таблицу — происходит после ответа или рядом с ним и не определяет судьбу заявки.

Ключевая мысль: ответ пользователю должен соответствовать тому, что заявка надёжно принята. Слово «надёжно» здесь означает конкретное: запись лежит в хранилище, которое переживёт перезапуск процесса. Для небольшой формы подойдёт обычная база на том же VPS, например SQLite с включённым журналированием или PostgreSQL, если он у вас уже есть. Что именно выбрать, зависит от нагрузки и привычек, но принцип одинаков: «принято» — это строка в таблице, а не отправленное письмо и не объект в памяти.

Минимальная таблица заявок может выглядеть так:

Поля таблицы заявок (пример схемы)
ПолеЗачем нужно
idВнутренний номер, который можно назвать посетителю и менеджеру
created_atВремя приёма: по нему менеджер сортирует очередь
name, contact, messageСодержательная часть заявки после проверки
attempt_keyКлюч, по которому распознаётся повторная отправка той же формы
notify_statusСостояние уведомления: ожидает, отправлено, ошибка
notify_errorПоследний текст ошибки доставки уведомления

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

Три разных дела, которые не стоит смешивать

В обработчике формы легко слепить в один блок валидацию, загрузку файла и отправку письма. Выглядит компактно, ломается неприятно. Эти три действия отличаются скоростью, рисками и тем, что делать при неудаче.

Валидация полей: быстро и без побочных эффектов

Проверка текста — это чистая функция: вход определён, выход тоже. Здесь отсекаются пустые поля, слишком длинные сообщения, явно неверный формат контакта. Если проверка не прошла, ничего не сохраняется, пользователь получает понятное сообщение с указанием поля, а браузер остаётся на странице с уже введёнными данными. Ограничения по длине стоит задать и на стороне сервера, даже если в HTML есть атрибуты проверки: браузерные атрибуты защищают от опечаток, а не от человека с командой curl.

Вложение: медленно и с чужим содержимым

Файл — самая тяжёлая и самая рискованная часть. Он приходит дольше текста, занимает место на диске и может оказаться чем угодно под видом PDF. Поэтому вложение лучше рассматривать как отдельный объект со своими правилами: максимальный размер, список допустимых расширений, собственное имя на диске. Имя, присланное посетителем, нельзя использовать как путь: файл сохраняется под сгенерированным идентификатором, а исходное имя хранится в базе только как отображаемая подпись.

Есть выбор, как соединить файл с заявкой. Первый вариант — отправлять текст и файл одним multipart-запросом. Это просто для посетителя: одно нажатие. Минус в том, что при ошибке в тексте файл придётся грузить заново, а при медленном соединении запрос длится долго. Второй вариант — сначала загрузить файл отдельным запросом, получить его идентификатор, затем отправить заявку, где идентификатор указан как ссылка. Он сложнее в интерфейсе, зато позволяет показать прогресс, повторить только неудавшуюся часть и не держать открытым запрос с текстом. Для формы с одним небольшим файлом хватит первого варианта; если файлы бывают крупными, второй обычно честнее. Конкретный API загрузки стоит проектировать под вашу форму, универсального рецепта здесь нет.

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

Уведомление: внешний мир, который ошибается

Отправка письма или сообщения менеджеру зависит от чужого почтового сервера, сети и настроек. Это единственная часть цепочки, которая нормально может не сработать с первой попытки. Поэтому её отделяют от приёма. Заявка уже записана, посетитель уже получил подтверждение, а уведомление идёт отдельным шагом с собственным статусом и повторами.

Почему письмо менеджеру не должно быть единственным экземпляром

Самый соблазнительный дизайн формы звучит так: принять данные и отправить их на почту. Никакой базы, никакого хранилища. Работает это ровно до первого сбоя, а сбои тут бытовые: почтовый сервер временно недоступен, письмо ушло в спам, ящик менеджера переполнен, адрес получателя сменился. В такой схеме письмо — единственная копия обращения. Если оно не дошло, заявки не существует ни у кого, при этом посетитель видел «Спасибо, мы свяжемся».

Различие, которое стоит проговорить вслух, такое:

  • Отказ почты — заявка сохранена, уведомление не доставлено. Это штатная неприятность: у записи статус «ошибка», система повторит попытку, менеджер увидит заявку в списке при следующем заходе.
  • Потеря обращения — запись не появилась, а пользователь уверен, что отправил. Это уже дефект, потому что клиент ждёт ответа, а у вас нет следа.

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

Здесь легко споткнуться об одну ловушку. Может показаться, что можно в одной транзакции записать заявку и отправить письмо, чтобы оба действия были «как одно». Но транзакция вашей базы данных не охватывает внешний почтовый сервер: если база подтвердила запись, а письмо не ушло, откатывать нечего, и наоборот. Поэтому надёжнее считать запись главным событием, а уведомление — вторичным действием, которое можно повторить. Один из практичных способов — держать статус уведомления в самой строке и периодически запускать небольшой воркер, который выбирает записи со статусом «ожидает» или «ошибка» и пробует отправить снова.

Принцип «быстро подтвердить, тяжёлое обработать отдельно» не изобретён для форм. Документация Stripe по webhook советует отделять долгую обработку события от быстрого успешного ответа endpoint (Stripe: Webhooks). Это другая задача, чем форма заявки, и готового обработчика формы там нет, но сама логика переносится: принимающая сторона отвечает, когда событие надёжно принято, а не когда всё доделано.

Медленный ответ и повторная отправка

Теперь представим посетителя на нестабильной мобильной сети. Он нажал «Отправить», кнопка задумалась, ответа нет. Через несколько секунд он нажимает ещё раз. Или браузер сам повторил запрос, или скрипт на странице сделал retry. В итоге на сервер пришли два одинаковых запроса, а менеджер получит две одинаковые заявки и, возможно, позвонит человеку дважды.

Начать стоит с интерфейса: после нажатия кнопка блокируется, рядом появляется состояние «отправляем». Но этого мало: блокировка не помогает, если ответ потерялся по дороге, а пользователь обновил страницу и отправил форму заново. Нужна защита на стороне сервера.

Рабочая схема такая. При открытии формы (или при первой отправке) страница генерирует случайный ключ попытки и отправляет его вместе с данными. Сервер хранит ключ в таблице заявок с ограничением уникальности. Если приходит запрос с уже известным ключом, сервер не создаёт вторую запись, а возвращает тот же ответ, что и в первый раз: «принято, номер такой-то». Для посетителя повтор выглядит как обычное подтверждение, для менеджера дубля нет.

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

У идеи есть аналог в мире платёжных уведомлений. Stripe в документации прямо говорит, что события могут доставляться повторно, а для распознавания повторов нужно отслеживать идентификатор события (Stripe: Webhooks). В форме роль идентификатора события играет ключ попытки, только создаёт его не внешний сервис, а ваша страница.

Таймауты на пути через nginx

Отдельный вопрос — что происходит, когда приложение отвечает долго, потому что принимает файл. Тут полезно знать, как устроен прокси. В документации nginx сказано, что proxy_read_timeout относится к интервалу между двумя последовательными чтениями ответа, а не к общей длительности ответа (nginx: HTTP proxy module). Для формы отсюда следует практический вывод: тайм-аут срабатывает на паузу, когда приложение молчит, а не на то, что операция в целом длинная. Если ваш обработчик молча «думает» дольше установленного интервала, прокси оборвёт соединение, хотя запись могла уже быть сделана. Именно поэтому дедупликация по ключу так важна: клиент увидит ошибку, нажмёт повторить, и сервер распознает уже принятую заявку.

Тем, кто захочет показывать пользователю ход обработки потоком, пригодится ещё одна деталь из той же документации: за буферизацию ответов отвечает proxy_buffering, при её отключении данные передаются клиенту по мере получения, а приложение может влиять на это заголовком X-Accel-Buffering, если конфигурация этого не запрещает (nginx: HTTP proxy module). Для обычной формы с коротким JSON-ответом это не нужно, а вот для живого индикатора прогресса может понадобиться. Включать такие настройки стоит только при реальной необходимости.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.

АРЕНДОВАТЬ СЕРВЕР

Простая защита от спама без мучений для людей

Любая публичная форма рано или поздно привлекает ботов. Они не взламывают сайт, они просто отправляют рекламу и ссылки в поле «сообщение». Цель защиты — отсечь основную массу такого мусора и не заставлять настоящих клиентов разгадывать картинки. Универсальной таблетки нет, поэтому полезно думать слоями, от дешёвых к дорогим.

  1. Скрытое поле-ловушка. В форму добавляется поле, невидимое человеку (его прячут стилями или выносят за пределы экрана), но заметное простому боту, который заполняет всё подряд. Если поле пришло непустым, заявка тихо отбрасывается, а боту возвращается обычный успешный ответ, чтобы он не учился обходу.
  2. Минимальное время заполнения. Человек не заполняет форму за секунду. Страница добавляет время открытия, сервер сравнивает его с временем отправки. Слишком быстрые отправки подозрительны. Порог лучше выбрать мягкий, чтобы не задевать тех, кого браузер заполнил автоматически.
  3. Ограничение частоты. Не больше нескольких заявок в минуту с одного адреса. В nginx для этого есть отдельные механизмы лимитирования запросов, их стоит настраивать по документации той версии, которая у вас стоит.
  4. Проверка содержимого. Ограничение числа ссылок в сообщении, обязательность контакта в понятном формате, отсечение явно рекламных шаблонов.
  5. Внешняя проверка-капча. Последний рубеж, если предыдущие слои не справляются. Она добавляет трение для людей и зависимость от стороннего сервиса, поэтому включать её стоит после наблюдения за реальным потоком, а не заранее.

Ещё одна мысль, которую легко упустить: отброшенный спам лучше не выбрасывать молча, а хотя бы считать. Счётчик «отсеяно за сутки» покажет, работает ли защита и не режет ли она живых людей. Если в какой-то день счётчик резко упал до нуля, а менеджеры жалуются на тишину, виновата, скорее всего, ваша ловушка.

Отдельно отметим: защита от спама и загрузка файлов проектируются самостоятельно, и пример приёма событий из документации Stripe тут не заменит собственных решений. У Stripe есть подпись событий и проверка по исходному телу запроса, и там сказано, что изменение тела до проверки делает её некорректной (Stripe: Webhooks). Для публичной формы такой подписи нет: у браузера посетителя нет общего секрета с вашим сервером, и любой может отправить любой POST. Поэтому доверять входным данным формы нельзя по определению, их всегда проверяют заново.

Вложения: права, место и срок жизни

Файлы от посторонних людей — это ответственность, которую хорошо ограничить с самого начала. Не нужно строить документооборот, достаточно нескольких правил, которые легко описать и проверить.

Хранить вне веб-корня. Каталог с загрузками не должен лежать там, откуда nginx раздаёт статику. Иначе загруженный файл станет доступен по прямой ссылке всем, кто её угадает, а если там окажется HTML или скрипт, браузер ещё и выполнит его от имени вашего домена. Менеджеру файл отдаёт приложение после проверки доступа, либо временная подписанная ссылка, если вы её реализуете.

Отдельный пользователь и минимальные права. Процесс приложения работает не от root, а от выделенной учётной записи. Каталог загрузок принадлежит ей, прав на запуск у файлов нет. Если процесс будет скомпрометирован, у него не окажется доступа к остальной системе. Root-доступ на VPS даёт вам возможность настроить эту схему самостоятельно, но ответственность за правильные права тоже ваша.

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

Срок хранения. Вложение — это чужие данные, и держать их вечно нет причин. Договоритесь, сколько файл нужен: например, пока заявка в работе и ещё какое-то время после закрытия. Простое задание по расписанию удаляет файлы старше выбранного срока и помечает это в базе. Конкретная цифра зависит от вашего бизнеса и от требований, которые к вам предъявляются, поэтому здесь мы её не называем.

Заявка и вложение согласованы. Порядок записи тут тоже важен. Сначала файл сохраняется на диск под временным именем, потом переименовывается и записывается в базу вместе с заявкой. Если процесс упал посередине, останется «сирота» — файл без записи. Раз в сутки полезно искать такие файлы и убирать их. Это маленькая уборка, а не отдельная система.

Что должно уместиться на небольшом сервере

Всё описанное — это один процесс приложения, nginx, база и несколько задач по расписанию. Это не кластер и не набор микросервисов: приложение, воркер уведомлений и уборщик файлов могут жить в одном коде и запускаться как отдельные команды одного проекта. Разделять их на самостоятельные сервисы нет причин, пока форма принимает заявки в человеческом, а не промышленном темпе.

Поэтому и сервер нужен скромный. На VPS MAATRIX доступны, например, тарифы Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) и Uab (2 vCPU, 4 ГБ, 80 ГБ). Это не гарантия ёмкости и не минимальные требования: сколько заявок и файлов выдержит ваша конфигурация, показывает только замер на ваших данных. Если на том же сервере живёт и статический сайт, и форма, ресурсы, скорее всего, будут расходоваться в основном на приём файлов и дисковое место. Именно диск — первое, на что стоит смотреть, если вложения крупные и их много, поэтому срок хранения и уборка из предыдущего раздела — не формальность. NVMe-накопитель ускоряет работу с файлами, но не отменяет необходимости считать место.

Среди публичных предложений каталога MAATRIX есть n8n и Nextcloud с установкой; их наличие и совместимую ОС стоит уточнять в форме заказа. Они могут пригодиться, если вы захотите передавать уведомления по более сложным сценариям или хранить файлы в отдельном файловом сервисе, но для маленькой формы это необязательное усложнение. Начните с минимального варианта, а добавляйте инструменты тогда, когда появится конкретная боль. Резервные копии стоит настроить самостоятельно: в предложении они в числе подтверждённых возможностей не указаны, так что рассчитывать на них «по умолчанию» нельзя. Для базы заявок достаточно регулярной выгрузки в другое место, и это один из немногих пунктов эксплуатации, который здесь действительно оправдан, потому что база — единственный экземпляр обращений.

Как выглядит сбой, когда всё сделано правильно

Разберём один условный сценарий на вымышленных данных, чтобы увидеть, как слои работают вместе. Посетитель заполняет форму, прикладывает PDF и нажимает кнопку. Страница генерирует ключ попытки и отправляет запрос. Сервер проверяет поля, видит, что ловушка пуста и время заполнения разумное, сохраняет файл под служебным именем, записывает заявку со статусом уведомления «ожидает» и возвращает ответ с номером. Посетитель видит подтверждение с номером заявки.

Дальше воркер пытается отправить письмо менеджеру, но почтовый сервер временно недоступен. Воркер пишет в заявку статус «ошибка» и текст причины. Через некоторое время он пробует снова, и на этот раз письмо уходит. Статус меняется на «отправлено». Если бы почта не работала сутки, заявка всё равно лежала бы в базе с понятным статусом, и любой человек, зашедший в список, увидел бы её.

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

Теперь сравним последствия трёх разных сбоев:

Что происходит при разных сбоях
СбойЧто видит посетительЧто остаётся в системе
Ошибка проверки полейСообщение у конкретного поля, форма не очищенаНичего не сохраняется
Сбой записи в базуСообщение об ошибке без подтверждения, можно повторитьЗаявки нет, и посетитель об этом знает
Отказ почты после записиПодтверждение с номеромЗаявка со статусом «ошибка», будет повтор уведомления

Важно, что во второй строке посетитель получает честную ошибку, а не ложное подтверждение, а в третьей — настоящее подтверждение, потому что заявка действительно принята. Именно это и называется соответствием подтверждения надёжно принятой заявке: слово «принято» говорится только тогда, когда запись уже лежит в хранилище.

Менеджер, у которого есть список

Остался вопрос, как менеджеру увидеть заявки, если письмо задержалось. Строить CRM для этого не нужно. Достаточно закрытой страницы за паролем (или даже простой команды, выводящей последние записи), где заявки отсортированы по времени и у каждой видно статус уведомления. Список отвечает на главный вопрос дня: что пришло и что ещё не обработано. Письмо же становится удобным сигналом «загляни сюда», а не единственным носителем информации.

В такой схеме задержка уведомления превращается из происшествия в мелкое неудобство. Менеджер открывает список утром, видит заявку с пометкой «уведомление ожидает», берёт её в работу и звонит клиенту. Письмо придёт позже или не придёт вовсе, но обращение найдено, файл приложен, номер совпадает с тем, который назвали посетителю. Для человека по ту сторону формы всё произошло так, как обещала страница: он отправил заявку, получил номер, и ему позвонили.