Nazad na blog

Serverless kontakt forma sa Cloudflare Workers i D1

Serverless kontakt forma sa Cloudflare Workers i D1

7. oktobar 2026. — Cloudflare

Objavljeno

7. oktobar 2026.

Vreme čitanja

2 min čitanja

Kategorija

Cloudflare

Oznake

  • Cloudflare Workers
  • D1
  • Serverless
  • Forms
  • Turnstile

(Uvod)Dajte statičkom sajtu funkcionalnu kontakt formu uz Cloudflare Worker, D1 bazu, Turnstile zaštitu od spama i trenutna obaveštenja. Besplatan plan, kompletan kod.

Statički sajtovi su brzi, jeftini i teško se kvare — dok vam ne zatreba kontakt forma. Uobičajena rešenja su SaaS za forme sa mesečnom pretplatom ili ceo backend. Postoji i lakša opcija: jedan Cloudflare Worker koji proverava poruku, čuva je u D1 (SQLite) bazi i javlja vam se. Na besplatnom planu, sajt malog preduzeća za to nikada neće platiti.

Ovo je rešenje iza forme za upite na ovom sajtu.

Arhitektura

text
Browser (static page)
  └─ POST /api/inquiry  ──►  Cloudflare Worker
                               ├─ verify Turnstile token
                               ├─ validate fields
                               ├─ INSERT into D1
                               └─ notify (Telegram / email)

1. Napravite projekat i bazu

bash
npm create cloudflare@latest contact-worker -- --type=hello-world --lang=ts
cd contact-worker
npx wrangler d1 create contact-db

Poslednja komanda ispisuje blok za povezivanje. Dodajte ga u Wrangler konfiguraciju:

wrangler.toml
name = "contact-worker"
main = "src/index.ts"
compatibility_date = "2026-10-01"

[[d1_databases]]
binding = "DB"
database_name = "contact-db"
database_id = "<the id wrangler printed>"

2. Migracija za tabelu

bash
npx wrangler d1 migrations create contact-db init
migrations/0001_init.sql
CREATE TABLE inquiries (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  name       TEXT NOT NULL,
  email      TEXT NOT NULL,
  message    TEXT NOT NULL,
  budget     TEXT,
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
  ip_country TEXT
);
CREATE INDEX idx_inquiries_created ON inquiries (created_at);
bash
npx wrangler d1 migrations apply contact-db --local   # for wrangler dev
npx wrangler d1 migrations apply contact-db --remote  # production

3. Worker

src/index.ts
interface Env {
  DB: D1Database
  TURNSTILE_SECRET: string
  TELEGRAM_TOKEN?: string
  TELEGRAM_CHAT_ID?: string
  ALLOWED_ORIGIN: string
}

const cors = (origin: string) => ({
  'Access-Control-Allow-Origin': origin,
  'Access-Control-Allow-Methods': 'POST, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type',
})

const json = (body: unknown, status: number, origin: string) =>
  new Response(JSON.stringify(body), {
    status,
    headers: { 'Content-Type': 'application/json', ...cors(origin) },
  })

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const origin = env.ALLOWED_ORIGIN
    // A 204 can't have a body, so the preflight gets its own response
    if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: cors(origin) })
    if (request.method !== 'POST') return json({ error: 'Method not allowed' }, 405, origin)

    let data: Record<string, unknown>
    try {
      data = await request.json()
    } catch {
      return json({ error: 'Invalid JSON' }, 400, origin)
    }

    // 1. Spam check
    const ip = request.headers.get('CF-Connecting-IP') ?? ''
    if (!(await verifyTurnstile(String(data.token ?? ''), ip, env.TURNSTILE_SECRET))) {
      return json({ error: 'Verification failed' }, 403, origin)
    }

    // 2. Validation
    const name = String(data.name ?? '').trim()
    const email = String(data.email ?? '').trim()
    const message = String(data.message ?? '').trim()
    const budget = data.budget ? String(data.budget).slice(0, 50) : null
    if (!name || name.length > 100) return json({ error: 'Name is required' }, 422, origin)
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return json({ error: 'Email is invalid' }, 422, origin)
    if (message.length < 10 || message.length > 5000) return json({ error: 'Message is too short or long' }, 422, origin)

    // 3. Store
    const country = (request as Request & { cf?: { country?: string } }).cf?.country ?? null
    await env.DB.prepare(
      'INSERT INTO inquiries (name, email, message, budget, ip_country) VALUES (?, ?, ?, ?, ?)',
    )
      .bind(name, email, message, budget, country)
      .run()

    // 4. Notify, without making the visitor wait for it
    if (env.TELEGRAM_TOKEN && env.TELEGRAM_CHAT_ID) {
      const text = `New enquiry from ${name} <${email}>\n\n${message.slice(0, 500)}`
      await fetch(`https://api.telegram.org/bot${env.TELEGRAM_TOKEN}/sendMessage`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ chat_id: env.TELEGRAM_CHAT_ID, text }),
      }).catch(() => {})
    }

    return json({ ok: true }, 200, origin)
  },
}

async function verifyTurnstile(token: string, ip: string, secret: string) {
  if (!token) return false
  const body = new FormData()
  body.append('secret', secret)
  body.append('response', token)
  if (ip) body.append('remoteip', ip)
  const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { method: 'POST', body })
  const outcome = (await res.json()) as { success: boolean }
  return outcome.success
}

Tri detalja vredna pažnje:

  • Uvek koristite prepare().bind(). Nikada ne sastavljajte SQL spajanjem stringova; vezani parametri onemogućavaju injekciju.
  • CORS je ograničen na vaš domen, pa drugi sajtovi ne mogu iz pregledača da šalju podatke na vaš endpoint.
  • Provera se radi na serveru. Provera na klijentu je radi udobnosti korisnika; podatke štiti Worker.

Ako ne želite da odgovor čeka na obaveštenje, koristite ctx.waitUntil(promise) sa trećim argumentom ctx funkcije fetch.

4. Tajne i objavljivanje

bash
npx wrangler secret put TURNSTILE_SECRET
npx wrangler secret put TELEGRAM_TOKEN
npx wrangler secret put TELEGRAM_CHAT_ID
npx wrangler deploy

ALLOWED_ORIGIN nije tajna; stavite ga u [vars] u Wrangler konfiguraciji. Za lokalni razvoj, tajne idu u fajl .dev.vars koji nikada ne commit-ujete.

5. Forma na statičkom sajtu

Dodajte Turnstile skriptu i kontejner, a zatim pošaljite formu kao JSON:

html
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

<form id="contact">
  <input name="name" required maxlength="100" />
  <input name="email" type="email" required />
  <textarea name="message" required minlength="10"></textarea>
  <div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>
  <button>Send</button>
</form>
js
document.getElementById('contact').addEventListener('submit', async (event) => {
  event.preventDefault()
  const form = new FormData(event.currentTarget)
  const res = await fetch('https://contact-worker.example.workers.dev', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      name: form.get('name'),
      email: form.get('email'),
      message: form.get('message'),
      token: form.get('cf-turnstile-response'),
    }),
  })
  // show a success or error message based on res.ok
})

U Reactu, isto to živi u Client Componenti sa useState za status — ili sa React Hook Form i Zod ako forma poraste.

Čitanje poruka

bash
npx wrangler d1 execute contact-db --remote \
  --command "SELECT created_at, name, email, substr(message, 1, 80) FROM inquiries ORDER BY id DESC LIMIT 20"

Za nešto prijatnije, dodajte Workeru GET rutu koja zahteva zaglavlje Authorization: Bearer <token> i vraća poslednje redove kao JSON, pa ih čitajte sa privatne admin stranice.

Lista za ojačavanje

  • Turnstile proveren na serveru, ne samo prikazan na klijentu
  • CORS ograničen na vaš domen
  • Ograničenja dužine za svako polje
  • Vezani parametri za svaki upit
  • Pravilo za ograničenje broja zahteva na toj ruti u Cloudflare kontrolnoj tabli (WAF → Rate limiting rules)
  • Tajne u wrangler secret, nikada u repozitorijumu
  • Plan za lične podatke: koliko dugo čuvate upite i kako da obrišete jedan na zahtev

Zašto je ovo bolje od servisa za forme

Podaci su vaši, za obim malog preduzeća ne košta ništa, nema skripti trećih strana koje prate posetioce, a možete ga proširiti — automatski odgovori, sinhronizacija sa CRM-om, korak za zakazivanje — sa nekoliko linija TypeScripta. Prirodno se uklapa sa statički izvezenim sajtom; pogledajte vodič za Next.js statički izvoz.

Bilten

Novi tekstovi na email

Kratak email kad objavim nešto novo. Bez spama, a pretplatu možete otkazati u bilo kom trenutku.

Core Web Vitals za animirane sajtove: LCP, INP i CLS
Core Web Vitals za animirane sajtove: LCP, INP i CLS
Sledeći tekst →