Статический сайт умеет только отдавать файлы: POST-запрос ему обрабатывать некому. На этой неделе на hogin.pro заработала живая форма обратной связи: Turnstile против ботов, серверная проверка токена, архив в D1 и мгновенный пуш в Telegram. Весь «бэкенд» — один TypeScript-файл в каталоге functions/, сайт остался статическим, а счёт от Cloudflare — нулевым. Разбираем по шагам, включая две грабли, которые стоили дороже всего кода.
Содержание
Открыть содержание
- Проблема: статике нечем ответить на POST
- Вся серверная часть — один файл в functions/
- Три слоя защиты от ботов
- D1 — источник истины, Telegram — best-effort
- Форма на Cloudflare против привычных вариантов
- Что нужно, чтобы собрать форму за вечер
- Грабли, которые мы поймали за вас
- Как проверить, что всё работает
- Итог
Проблема: статике нечем ответить на POST
Собранный Astro-блог — просто файлы в dist/: отправить сообщение через них нельзя, обрабатывать POST некому. К чему обычно приходят:
- SaaS-форма (Formspree и похожие) — быстро, но free-тариф — десятки отправок в месяц, дальше подписка, а сообщения лежат у третьей стороны;
- отдельный VPS с nginx + php — целый сервер ради одной формы: ОС, патчи, TLS, мониторинг — ради скрипта, дописывающего строку в файл; Форме нужно: принять POST, отличить бота, сохранить сообщение и позвать автора. Free-тариф Cloudflare закрывает всё: 100 000 запросов/день на Functions, D1 и Turnstile бесплатны.
Вся серверная часть — один файл в functions/
У Pages есть недооценённая фича: каталог functions/ рядом со статикой разворачивается как Workers. Файл functions/api/contact.ts становится маршрутом POST /api/contact — ни своего роутера, ни SSR-адаптера Astro, ни отдельного деплоя. Сайт остаётся статикой, «бэкенд» — файл в репозитории.
- страница — обычная Astro-страница: поля, honeypot и виджет Turnstile в режиме Managed — Cloudflare сам решает, показывать ли человеку чекбокс;
- POST /api/contact — Pages Function: валидация полей → серверная проверка токена через
siteverify→ INSERT в D1 → sendMessage в Telegram; - D1 и Telegram — сообщение сохранено и доставлено пушем на телефон.
Три слоя защиты от ботов
Открытая форма живёт часами: боты находят endpoint, начинается вал спама. До базы запрос проходит три фильтра.
- honeypot с фейк-успехом — скрытое от людей поле; автозаполняющий всё подряд бот заполняет и его — и получает честный 200 OK «отправлено», так и не узнав, что сообщения нет. Фейк-успех важнее отказа: бот не отличает отказ от доставки;
- лимиты полей — длины и формат полей, максимум 5000 символов сообщения — дешёвый фильтр, отбрасывающий мусор до обращения к внешним сервисам;
- siteverify — серверная проверка токена. Sitekey публичный и вшивается в билд, но HTML не источник истины: токен проверяется на
siteverifyс секретом, живущим только на сервере. Токен одноразовый — после отправки форма вызываетturnstile.reset().
D1 — источник истины, Telegram — best-effort
D1 — обязательный шаг: INSERT не удался — клиент получает 500, пользователь видит ошибку и повторяет. Telegram — уведомление поверх: sendMessage упал — в лог пишется warning, но клиент всё равно получает 200 OK: сообщение уже в базе, пуш можно повторить руками.
Разделение убирает обидную ошибку «Telegram лежит — читатель не смог написать»: уведомление не должно иметь права терять сообщение.
Форма на Cloudflare против привычных вариантов
| Formspree и SaaS | VPS + nginx + php | Pages + D1 + Telegram | |
|---|---|---|---|
| Цена | десятки отправок/мес, дальше ~$20 | $4–6/мес + время на патчи | $0: всё в free-тарифе |
| Что обслуживаете | ничего, но форма у чужого сервиса | ОС, TLS, PHP, мониторинг | ничего: код в репозитории |
| Защита от ботов | фильтр сервиса | ставьте сами | honeypot + Turnstile + siteverify |
| Сообщения | в кабинете SaaS | в вашей БД | в вашем D1, SQL-запросом |
Что нужно, чтобы собрать форму за вечер
Нужны сайт на Astro на Pages и wrangler (npm i -g wrangler). Порядок такой.
1. Виджет Turnstile. В дашборде Cloudflare: Turnstile → Add site → Managed. Получите sitekey (публичный) и secret.
2. Бот в Telegram. У @BotFather — /newbot, токен вида 123456789:AA…. Свой числовой ID возьмите у @userinfobot и нажмите /start у бота — пока диалог не начат, Telegram запрещает боту писать первым.
3. База D1 и таблица:
wrangler d1 create hogin-forms # запомните database_id
-- migrations/0001_init.sql
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL,
message TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
wrangler d1 execute hogin-forms --remote --file migrations/0001_init.sql
4. Форма на Astro-странице — поля, honeypot и виджет:
---
const siteKey = import.meta.env.PUBLIC_TURNSTILE_SITE_KEY;
---
<form id="contact-form" method="post" action="/api/contact">
<input type="text" name="name" maxlength="100" required>
<input type="email" name="email" maxlength="200" required>
<textarea name="message" maxlength="5000" required></textarea>
<!-- honeypot: скрыт от людей, заметен ботам -->
<input type="text" name="website" tabindex="-1" autocomplete="off"
class="hp" aria-hidden="true">
<div class="cf-turnstile" data-sitekey={siteKey}></div>
<button type="submit">Отправить</button>
</form>
<script is:inline src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<script>
document.getElementById("contact-form")?.addEventListener("submit", async (e) => {
e.preventDefault();
const form = e.currentTarget as HTMLFormElement;
const { ok } = await (await fetch(form.action, {
method: "POST", body: new FormData(form),
})).json();
form.reset();
(window as any).turnstile?.reset(); // токен одноразовый — просим новый
});
</script>
5. Весь «бэкенд» — functions/api/contact.ts:
interface Env {
DB: D1Database;
TURNSTILE_SECRET: string;
TG_BOT_TOKEN: string;
TG_CHAT_ID: string;
}
export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => {
const form = await request.formData();
const name = String(form.get("name") ?? "").trim();
const email = String(form.get("email") ?? "").trim();
const message = String(form.get("message") ?? "").trim();
const honeypot = String(form.get("website") ?? "");
const token = String(form.get("cf-turnstile-response") ?? "");
// фильтр 1: бот заполнил honeypot — фейк-успех, в базу не пишем
if (honeypot) return Response.json({ ok: true });
// фильтр 2: лимиты длин и формат email
if (!name || name.length > 100)
return Response.json({ ok: false, error: "name" }, { status: 400 });
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email) || email.length > 200)
return Response.json({ ok: false, error: "email" }, { status: 400 });
if (message.length < 5 || message.length > 5000)
return Response.json({ ok: false, error: "message" }, { status: 400 });
// фильтр 3: серверная проверка токена Turnstile
const verify = new URLSearchParams({ secret: env.TURNSTILE_SECRET, response: token });
const check = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify",
{ method: "POST", body: verify });
const { success } = await check.json<{ success: boolean }>();
if (!success)
return Response.json({ ok: false, error: "captcha" }, { status: 403 });
// D1 — источник истины: сбой здесь = честная ошибка клиенту
await env.DB.prepare(
`INSERT INTO messages (id, name, email, message) VALUES (?1, ?2, ?3, ?4)`,
).bind(crypto.randomUUID(), name, email, message).run();
// Telegram — best-effort: сбой не теряет сообщение и не волнует пользователя
try {
await fetch(`https://api.telegram.org/bot${env.TG_BOT_TOKEN}/sendMessage`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
chat_id: env.TG_CHAT_ID,
text: `📩 ${name} <${email}>\n\n${message}`,
}),
});
} catch (e) {
console.warn("telegram send failed:", e);
}
return Response.json({ ok: true });
};
6. Переменные в Pages (Settings → Variables and Secrets):
| Переменная | Тип | Зачем |
|---|---|---|
PUBLIC_TURNSTILE_SITE_KEY | Plain text | в билд, публичный |
TURNSTILE_SECRET | Secret | siteverify в рантайме |
TG_BOT_TOKEN | Secret | отправка в Telegram |
TG_CHAT_ID | Plain text | ваш ID, не секрет |
Плюс Settings → Functions → D1 bindings: DB → hogin-forms (Production). Пуш в main собирает статику и функцию одним деплоем.
Грабли, которые мы поймали за вас
Грабля 1: секрет не попадает в билд. PUBLIC_TURNSTILE_SITE_KEY нужен Astro при сборке, а Secret-переменные передаются только в рантайм функции. Объявите sitekey как Secret — сборка молча подставит пусто, виджет повиснет с плашкой «Только для тестирования», отправки фейлятся в siteverify. Лечение: тип Plain text и Redeploy.
Грабля 2: chat_id — это не бот. В токене 123456789:AA… число перед двоеточием — ID самого бота; поставить его в chat_id — получить 403 Forbidden: the bot can't send messages to the bot. Правильный chat_id — ваш числовой ID, бот должен быть «начат» через /start (шаг 2).
Искать в слепую бесполезно — спасают живые логи:
wrangler pages deployment tail <deployment-id> # логи деплоя в реальном времени
Обе нашлись именно этой командой: пустой sitekey с провалившимся siteverify и 403 от Telegram с исчерпывающим сообщением. Для истории включите Observability — Pages хранит логи функций, даже когда никто не слушает.
Как проверить, что всё работает
Локально — смоук-тест без реальных ключей: у Turnstile есть тестовые sitekey (1x000…AA проходит, 2x000… фейлится) и такие же secret:
pnpm build && wrangler pages dev dist # функция и статика на localhost:8788
Контрольная отправка:
# 1. без токена Turnstile — отказ
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://example.com/api/contact \
-d 'name=Test&[email protected]&message=hello world' # 403
# 2. сообщение — в базе
wrangler d1 execute hogin-forms --remote \
--command "SELECT id, name, email, created_at FROM messages ORDER BY created_at DESC LIMIT 5;"
# 3. пуш пришёл на телефон
Итог
Статическому блогу не нужен бэкенд, чтобы принять сообщение от читателя: вся серверная логика — один файл в functions/, исполнение и хранение бесплатные, Главное открытие вечера: сложности были не в коде, а в конфигурации — тип переменной (secret против plain) и chat_id бота стоили больше времени, чем вся форма. Код пишется за час; читайте, что Cloudflare пишет в логах. Раз форма открывает наружу ещё один вход, рядом не лишним будет WAF — self-hosted вариант разбирали отдельно.