Перейти к содержимому
HoginHogin
Назад

Форма обратной связи на статическом сайте: Cloudflare Turnstile + Pages Functions + D1 + Telegram — без своего бэкенда

8 мин чтения

Статический сайт умеет только отдавать файлы: POST-запрос ему обрабатывать некому. На этой неделе на hogin.pro заработала живая форма обратной связи: Turnstile против ботов, серверная проверка токена, архив в D1 и мгновенный пуш в Telegram. Весь «бэкенд» — один TypeScript-файл в каталоге functions/, сайт остался статическим, а счёт от Cloudflare — нулевым. Разбираем по шагам, включая две грабли, которые стоили дороже всего кода.

Содержание

Открыть содержание

Проблема: статике нечем ответить на POST

Собранный Astro-блог — просто файлы в dist/: отправить сообщение через них нельзя, обрабатывать POST некому. К чему обычно приходят:

Вся серверная часть — один файл в functions/

У Pages есть недооценённая фича: каталог functions/ рядом со статикой разворачивается как Workers. Файл functions/api/contact.ts становится маршрутом POST /api/contact — ни своего роутера, ни SSR-адаптера Astro, ни отдельного деплоя. Сайт остаётся статикой, «бэкенд» — файл в репозитории.

Путь сообщения: от кнопки до телефона

Три слоя защиты от ботов

Открытая форма живёт часами: боты находят endpoint, начинается вал спама. До базы запрос проходит три фильтра.

Три фильтра между ботом и базой

D1 — источник истины, Telegram — best-effort

D1 — обязательный шаг: INSERT не удался — клиент получает 500, пользователь видит ошибку и повторяет. Telegram — уведомление поверх: sendMessage упал — в лог пишется warning, но клиент всё равно получает 200 OK: сообщение уже в базе, пуш можно повторить руками.

D1 — источник истины, Telegram — best-effort

Разделение убирает обидную ошибку «Telegram лежит — читатель не смог написать»: уведомление не должно иметь права терять сообщение.

Форма на Cloudflare против привычных вариантов

Formspree и SaaSVPS + nginx + phpPages + 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_KEYPlain textв билд, публичный
TURNSTILE_SECRETSecretsiteverify в рантайме
TG_BOT_TOKENSecretотправка в Telegram
TG_CHAT_IDPlain 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 вариант разбирали отдельно.


Поделиться:

Следующая статья
Та же архитектура, но self-hosted: AWS-схема из части 1 на своей VM за $0/мес (часть 2/2)