HTML 表單 → Worker → 資料庫
一個聯絡或註冊表單:POST 到 Worker、通過驗證,再存進 D1 資料庫。
我們要串什麼?
這是網頁開發最常見的任務:訪客填寫表單、你把他輸入的內容存起來、再回覆他成功了。我們要把三個層串在一起——前端 HTML 表單、一個 Cloudflare Worker(在邊緣執行的伺服器端程式碼)、以及一個 D1 資料庫(無伺服器的 SQL 資料庫),每一筆送出都會變成資料表裡的一列。
把它想成…
一個服務櫃台。訪客遞交一張紙本表單(HTML 表單)。櫃台人員檢查是不是都填對了(Worker 驗證),再歸檔進抽屜(D1)。如果有欄位空白,櫃台會把表單退回來、指出問題——它永遠不會進抽屜。
每一層負責什麼
每一層都只負責一件事。把它們分開,才能讓應用既好理解又安全。
前端(表單)
用 HTML 做出欄位,再用 JS 攔截送出動作、用 fetch 把資料傳出去,並負責顯示成功或錯誤訊息。
Worker(把關人)
接收 POST、驗證每一個欄位,確認無誤才寫進資料庫。這是唯一你真正能信任的地方。
D1(儲存)
一個 SQL 資料庫,把每筆送出存成一列。Worker 透過 env.DB binding(綁定)跟它溝通。
Turnstile(選配)
免費、不用解拼圖的機器人檢查。加上它可以防止垃圾訊息灌爆你的資料表。
同一個網域、兩條路徑
透過 Workers 静態資源(static assets),同一個 Worker 可以同時伺服你的 HTML 頁面(例如 /)跟處理 API(/api/submit)。因為同源,fetch 就不需要設定 CORS。
資料模型
我們只需要一個資料表:submissions。每一列就是一筆填好的表單。id 會自動遞增,created_at 會自動蓋上時間,讓你知道每筆訊息什麼時候進來的。
-- schema.sql
CREATE TABLE IF NOT EXISTS submissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL,
message TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);讓資料庫蓋時間戳
用 DEFAULT (datetime('now')) 代表你永遠不用從前端傳 created_at——時間由伺服器決定,所以偽造不了。
請求流程
跟著一筆送出,從點擊一路看到回覆。注意驗證是在 Worker 上進行,而且只有當每個欄位都合法時,才會動到資料庫。
狀態碼就是約定
Worker 成功時回 201(已建立)、輸入不合法時回 400(請求有誤)。前端看 res.ok 來決定要清空表單還是顯示錯誤。
一步步動手串
下面是全部:資料庫與資料表、wrangler 設定、前端表單,以及把它們串起來的 Worker。把每一段貼進對應的檔案里。
建立資料庫與資料表
把上面的 CREATE TABLE 存成 schema.sql,再跑這兩行。--remote 代表「套用到雲端上真正的資料庫」(不加就是在本機測試)。
# 1) Create the database (copy the printed database_id) npx wrangler d1 create contact-db # 2) Apply the table from schema.sql to the real cloud database npx wrangler d1 execute contact-db --remote --file=./schema.sql在 wrangler.jsonc 綁定 D1
貼上第 1 步拿到的 database_id。binding 名稱 DB 就是你的 Worker 用 env.DB 存取資料庫的方式。assets 那段讓同一個 Worker 能伺服你的 HTML。
{ "name": "contact-form", "main": "src/index.js", "compatibility_date": "2025-01-01", "assets": { "directory": "./public" }, "d1_databases": [ { "binding": "DB", "database_name": "contact-db", "database_id": "<paste-your-id-here>" } ] }做出前端表單
把這個存成 public/index.html。它用 FormData 收集欄位、用 fetch 以 JSON 送出,再依回應更新狀態文字。
<!doctype html> <html lang="zh-Hant"> <head> <meta charset="utf-8" /> <title>Contact us</title> </head> <body> <form id="contact-form"> <input name="name" type="text" placeholder="Your name" required /> <input name="email" type="email" placeholder="you@example.com" required /> <textarea name="message" placeholder="Message" required></textarea> <button type="submit">Send</button> <p id="status"></p> </form> <script> const form = document.getElementById('contact-form'); const statusEl = document.getElementById('status'); form.addEventListener('submit', async (e) => { e.preventDefault(); // stop the normal full-page reload statusEl.textContent = 'Sending...'; // FormData -> plain object -> JSON the Worker can read const data = Object.fromEntries(new FormData(form)); const res = await fetch('/api/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); const result = await res.json(); if (res.ok) { statusEl.textContent = 'Thanks! We received your message.'; form.reset(); } else { // result.errors looks like { email: 'Invalid email' } statusEl.textContent = 'Please fix: ' + Object.values(result.errors).join(', '); } }); </script> </body> </html>寫 Worker(驗證 + 寫入)
把這個存成 src/index.js。它檢查方法與路徑、驗證每個欄位,並用帶 bind() 的預備語句,讓使用者輸入永遠不會變成 SQL。
export default { async fetch(request, env) { const url = new URL(request.url); // Only the form endpoint is handled here if (request.method !== 'POST' || url.pathname !== '/api/submit') { return new Response('Not found', { status: 404 }); } // 1. Read the body safely (bad JSON must not crash the Worker) let body; try { body = await request.json(); } catch { return Response.json({ errors: { form: 'Invalid JSON' } }, { status: 400 }); } // 2. Validate EVERY field on the server. Never trust the client. const errors = {}; const name = String(body.name || '').trim(); const email = String(body.email || '').trim(); const message = String(body.message || '').trim(); if (name.length < 2) errors.name = 'Name is too short'; if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) errors.email = 'Invalid email'; if (message.length < 5) errors.message = 'Message is too short'; if (message.length > 2000) errors.message = 'Message is too long'; if (Object.keys(errors).length > 0) { return Response.json({ errors }, { status: 400 }); } // 3. Insert with a prepared statement. bind() fills the ? holes // so user input can never become SQL (blocks SQL injection). await env.DB .prepare('INSERT INTO submissions (name, email, message) VALUES (?, ?, ?)') .bind(name, email, message) .run(); // 4. Confirm success with 201 Created return Response.json({ ok: true }, { status: 201 }); }, };(選配)用 Turnstile 擋機器人
在表單加上 Turnstile widget,再在 Worker 最上方(驗證之前)驗證 token——讓機器人在碰到你的驗證或資料庫之前就被擋下。
// Optional: block bots BEFORE you validate or insert. // Add a Turnstile widget to the form (see the Turnstile guide), // it adds a "cf-turnstile-response" token to the submitted data. const token = body['cf-turnstile-response']; const verify = await fetch( 'https://challenges.cloudflare.com/turnstile/v0/siteverify', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ secret: env.TURNSTILE_SECRET_KEY, // a Worker secret, never hardcoded response: token || '', remoteip: request.headers.get('CF-Connecting-IP') || '', }), } ); const outcome = await verify.json(); if (!outcome.success) { return Response.json({ errors: { form: 'Bot check failed' } }, { status: 403 }); }部署上線
發布這個 Worker。你的表單頁面跟 API 就在同一個網址上線了。
npx wrangler deploy
伺服器端驗證,一條條分支
這就是 Worker 對每個請求做的判斷。恰好只有一條順利路徑(儲存並回 201)和一條拒絕路徑(回 400 並附上哪裡有問題的清單)。
絕不要信任前端——為什麼?
HTML 上的 required 或 type=email 只是給老實使用者的提示。任何人都可以打開開發者工具,或用 curl 直接送一個原始請求,完全跳過頁面。Worker 才是真正的守門員:它必須自己重新檢查長度、格式與必填欄位,因為它是攻擊者唯一改不了的程式碼。
重點概念
preventDefault()
阻止瀏覽器預設的表單送出(整頁重新載入),讓你的 JS 改用 fetch 把資料送出。
FormData
一個內建物件,能抓出表單裡所有有 name 的欄位。Object.fromEntries 再把它變成可以交給 JSON.stringify 的純物件。
預備語句
prepare() 加 bind() 把使用者輸入跟 SQL 文字分開。? 佔位符是當「值」填進去的,所以輸入永遠不會被當程式執行——這能擋下 SQL 注入攻擊。
Binding(env.DB)
binding 是你 Worker 到某個資源的一條具名連線。wrangler.jsonc 把名稱 DB 對到你的資料庫;程式裡就是 env.DB。
HTTP 狀態碼
201 = 已建立(成功)。400 = 請求有誤(你的輸入不對)。403 = 禁止(例如機器人檢查未通過)。前端依這些狀態碼分支。
Turnstile token
widget 加進表單的一次性通行證。Worker 把它送到 Cloudflare 的 Siteverify API,確認是真人送出的。
陷阱與小提示
先驗證、再儲存——順序別錯
一個常見的 bug 是先 INSERT 再驗證。請務必在 INSERT 之前就拒絕不合法的輸入,你的資料表才不會被垃圾或超大的資料塞滿。
- 回傳一個結構化的 errors 物件(每個欄位一個),讓前端能指出每個問題,而不是只說「失敗」。
- 在伺服器端 trim 輸入並限制長度;千萬不要只靠 HTML 的 maxlength。
- 把 request.json() 包進 try/catch——格式不對的 body 應該回一個乾淨的 400,而不是讓程式崩潰。
- 如果你常列出最近的送出,在 created_at 建立索引(index)以減少讀取的列數。
- 用 'wrangler secret put TURNSTILE_SECRET_KEY' 存放 Turnstile 密鑰——絕不要寫死在程式裡。
- D1 免費額度每天給 500 萬列讀取、10 萬列寫入——對一個聯絡表單絕對夠用。
縱深防禦
把防禦分層疊起來:HTML 提示管體驗、Worker 驗證管正確性、預備語句擋注入、Turnstile 擋機器人。單靠任一層都不夠。