sell整合實戰
dynamic_form

HTML 表單 → Worker → 資料庫

一個聯絡或註冊表單:POST 到 Worker、通過驗證,再存進 D1 資料庫。

3層串接
1個資料表存資料
201成功建立
400輸入不合法
insights

我們要串什麼?

這是網頁開發最常見的任務:訪客填寫表單、你把他輸入的內容存起來、再回覆他成功了。我們要把三個層串在一起——前端 HTML 表單、一個 Cloudflare Worker(在邊緣執行的伺服器端程式碼)、以及一個 D1 資料庫(無伺服器的 SQL 資料庫),每一筆送出都會變成資料表裡的一列。

local_post_office

把它想成…

一個服務櫃台。訪客遞交一張紙本表單(HTML 表單)。櫃台人員檢查是不是都填對了(Worker 驗證),再歸檔進抽屜(D1)。如果有欄位空白,櫃台會把表單退回來、指出問題——它永遠不會進抽屜。

schema架構一眼看

填寫並送出

fetch POST /api/submit

INSERT 一筆資料

成功 / 失敗

JSON 回應

顯示成功或錯誤

訪客 / 瀏覽器

前端網頁 (HTML + JS)

Worker (驗證 + 邏輯)

D1 資料庫

account_tree

每一層負責什麼

每一層都只負責一件事。把它們分開,才能讓應用既好理解又安全。

web

前端(表單)

用 HTML 做出欄位,再用 JS 攔截送出動作、用 fetch 把資料傳出去,並負責顯示成功或錯誤訊息。

verified_user

Worker(把關人)

接收 POST、驗證每一個欄位,確認無誤才寫進資料庫。這是唯一你真正能信任的地方。

database

D1(儲存)

一個 SQL 資料庫,把每筆送出存成一列。Worker 透過 env.DB binding(綁定)跟它溝通。

shield_lock

Turnstile(選配)

免費、不用解拼圖的機器人檢查。加上它可以防止垃圾訊息灌爆你的資料表。

dns

同一個網域、兩條路徑

透過 Workers 静態資源(static assets),同一個 Worker 可以同時伺服你的 HTML 頁面(例如 /)跟處理 API(/api/submit)。因為同源,fetch 就不需要設定 CORS。

schema

資料模型

我們只需要一個資料表:submissions。每一列就是一筆填好的表單。id 會自動遞增,created_at 會自動蓋上時間,讓你知道每筆訊息什麼時候進來的。

schemasubmissions 資料表

SUBMISSIONS

integer

id

PK

主鍵

text

name

訪客姓名

text

email

聯絡信箱

text

message

訊息內容

text

created_at

建立時間

sqlschema.sql
-- 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'))
);
schedule

讓資料庫蓋時間戳

用 DEFAULT (datetime('now')) 代表你永遠不用從前端傳 created_at——時間由伺服器決定,所以偽造不了。

swap_vert

請求流程

跟著一筆送出,從點擊一路看到回覆。注意驗證是在 Worker 上進行,而且只有當每個欄位都合法時,才會動到資料庫。

schema送出 → 驗證 → 儲存 → 回覆
"D1 資料庫""Worker""前端網頁""訪客""D1 資料庫""Worker""前端網頁""訪客"alt[欄位有效][欄位無效]填寫表單並按下送出POST /api/submit (JSON)驗證欄位 (姓名 / 信箱 / 訊息)INSERT INTO submissions寫入成功201 Created + JSON顯示成功訊息400 + 錯誤清單顯示欄位錯誤
swap_horiz

狀態碼就是約定

Worker 成功時回 201(已建立)、輸入不合法時回 400(請求有誤)。前端看 res.ok 來決定要清空表單還是顯示錯誤。

construction

一步步動手串

下面是全部:資料庫與資料表、wrangler 設定、前端表單,以及把它們串起來的 Worker。把每一段貼進對應的檔案里。

  1. 建立資料庫與資料表

    把上面的 CREATE TABLE 存成 schema.sql,再跑這兩行。--remote 代表「套用到雲端上真正的資料庫」(不加就是在本機測試)。

    bash
    # 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
  2. 在 wrangler.jsonc 綁定 D1

    貼上第 1 步拿到的 database_id。binding 名稱 DB 就是你的 Worker 用 env.DB 存取資料庫的方式。assets 那段讓同一個 Worker 能伺服你的 HTML。

    json
    {
      "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>"
        }
      ]
    }
  3. 做出前端表單

    把這個存成 public/index.html。它用 FormData 收集欄位、用 fetch 以 JSON 送出,再依回應更新狀態文字。

    html
    <!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>
  4. 寫 Worker(驗證 + 寫入)

    把這個存成 src/index.js。它檢查方法與路徑、驗證每個欄位,並用帶 bind() 的預備語句,讓使用者輸入永遠不會變成 SQL。

    js
    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 });
      },
    };
  5. (選配)用 Turnstile 擋機器人

    在表單加上 Turnstile widget,再在 Worker 最上方(驗證之前)驗證 token——讓機器人在碰到你的驗證或資料庫之前就被擋下。

    js
    // 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 });
    }
  6. 部署上線

    發布這個 Worker。你的表單頁面跟 API 就在同一個網址上線了。

    bash
    npx wrangler deploy

伺服器端驗證,一條條分支

這就是 Worker 對每個請求做的判斷。恰好只有一條順利路徑(儲存並回 201)和一條拒絕路徑(回 400 並附上哪裡有問題的清單)。

schema驗證分支

收到 POST 請求

解析 JSON 內容

欄位全部通過?

INSERT INTO submissions

回傳 201 + 成功

收集錯誤訊息

回傳 400 + 錯誤清單

gpp_bad

絕不要信任前端——為什麼?

HTML 上的 required 或 type=email 只是給老實使用者的提示。任何人都可以打開開發者工具,或用 curl 直接送一個原始請求,完全跳過頁面。Worker 才是真正的守門員:它必須自己重新檢查長度、格式與必填欄位,因為它是攻擊者唯一改不了的程式碼。

school

重點概念

block

preventDefault()

阻止瀏覽器預設的表單送出(整頁重新載入),讓你的 JS 改用 fetch 把資料送出。

dataset

FormData

一個內建物件,能抓出表單裡所有有 name 的欄位。Object.fromEntries 再把它變成可以交給 JSON.stringify 的純物件。

key

預備語句

prepare() 加 bind() 把使用者輸入跟 SQL 文字分開。? 佔位符是當「值」填進去的,所以輸入永遠不會被當程式執行——這能擋下 SQL 注入攻擊。

link

Binding(env.DB)

binding 是你 Worker 到某個資源的一條具名連線。wrangler.jsonc 把名稱 DB 對到你的資料庫;程式裡就是 env.DB。

tag

HTTP 狀態碼

201 = 已建立(成功)。400 = 請求有誤(你的輸入不對)。403 = 禁止(例如機器人檢查未通過)。前端依這些狀態碼分支。

smart_toy

Turnstile token

widget 加進表單的一次性通行證。Worker 把它送到 Cloudflare 的 Siteverify API,確認是真人送出的。

tips_and_updates

陷阱與小提示

report

先驗證、再儲存——順序別錯

一個常見的 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 萬列寫入——對一個聯絡表單絕對夠用。
verified

縱深防禦

把防禦分層疊起來:HTML 提示管體驗、Worker 驗證管正確性、預備語句擋注入、Turnstile 擋機器人。單靠任一層都不夠。