المواقع الثابتة سريعة ورخيصة ويصعب أن تتعطل، إلى أن تحتاج إلى نموذج تواصل. الحلول المعتادة إمّا خدمة نماذج باشتراك شهري، أو خلفية برمجية كاملة. وهناك خيار أخف: Cloudflare Worker واحد يتحقق من البيانات المرسلة، ويخزّنها في قاعدة بيانات D1 (SQLite)، ثم ينبّهك. وعلى الخطة المجانية، لن يدفع موقع شركة صغيرة شيئًا مقابله.
هذا هو الإعداد الذي يقف خلف نموذج الاستفسارات في هذا الموقع.
البنية
Browser (static page)
└─ POST /api/inquiry ──► Cloudflare Worker
├─ verify Turnstile token
├─ validate fields
├─ INSERT into D1
└─ notify (Telegram / email)1. إنشاء المشروع وقاعدة البيانات
npm create cloudflare@latest contact-worker -- --type=hello-world --lang=ts
cd contact-worker
npx wrangler d1 create contact-dbيطبع الأمر الأخير كتلة ربط (binding). أضفها إلى إعدادات Wrangler:
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. ترحيل (Migration) للجدول
npx wrangler d1 migrations create contact-db initCREATE 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);npx wrangler d1 migrations apply contact-db --local # for wrangler dev
npx wrangler d1 migrations apply contact-db --remote # production3. الـ Worker
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
}ثلاث تفاصيل تستحق الانتباه:
- استخدم دائمًا
prepare().bind(). لا تبنِ استعلامات SQL بدمج النصوص أبدًا؛ فالمعاملات المربوطة تجعل الحقن مستحيلًا. - سياسة CORS مقصورة على نطاقك، فلا تستطيع مواقع أخرى الإرسال إلى نقطة النهاية الخاصة بك من المتصفح.
- التحقق يجري على الخادم. التحقق في المتصفح لراحة المستخدم، أما ما يحمي البيانات فهو الـ Worker.
إن لم ترغب في أن تنتظر الاستجابة إرسالَ الإشعار، فاستخدم ctx.waitUntil(promise) مع الوسيط الثالث ctx في fetch.
4. الأسرار والنشر
npx wrangler secret put TURNSTILE_SECRET
npx wrangler secret put TELEGRAM_TOKEN
npx wrangler secret put TELEGRAM_CHAT_ID
npx wrangler deployALLOWED_ORIGIN ليس سرًّا؛ ضعه ضمن [vars] في إعدادات Wrangler. وللتطوير المحلي توضع الأسرار في ملف .dev.vars لا تودعه في المستودع أبدًا.
5. النموذج في الموقع الثابت
أضف سكربت أداة Turnstile وحاوية لها، ثم أرسل النموذج بصيغة JSON:
<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>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
})في React، يوضع الشيء نفسه داخل Client Component مع useState لحالة الإرسال، أو مع React Hook Form وZod إذا كبر النموذج.
قراءة الرسائل الواردة
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"ولطريقة أكثر راحة، أضف إلى الـ Worker مسار GET يشترط ترويسة Authorization: Bearer <token> ويعيد أحدث الصفوف بصيغة JSON، واقرأها من صفحة إدارة خاصة.
قائمة التحصين
- التحقق من Turnstile على الخادم، لا عرضه في المتصفح فقط
- تقييد CORS بنطاقك
- حدود للطول على كل حقل
- معاملات مربوطة في كل استعلام
- قاعدة لتحديد معدل الطلبات على المسار في لوحة تحكم Cloudflare (WAF ← Rate limiting rules)
- الأسرار في
wrangler secret، وليس في المستودع أبدًا - خطة للبيانات الشخصية: كم تحتفظ بالاستفسارات، وكيف تحذف أحدها عند الطلب
لماذا يتفوق هذا على خدمات النماذج
البيانات ملكك، ولا يكلّف شيئًا بأحجام الشركات الصغيرة، ولا يوجد سكربت لطرف ثالث يتتبع زوارك، ويمكنك توسيعه — ردود تلقائية، أو مزامنة مع نظام CRM، أو خطوة حجز — ببضعة أسطر من TypeScript. وهو يتكامل بطبيعته مع موقع مُصدَّر بشكل ثابت؛ راجع دليل التصدير الثابت في Next.js.

