1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Cloudflare Pages Functions と D1 で、サーバーを持たずに先行登録フォームを作る(重複・連投・ボット対策つき)

1
Posted at

個人で作っているサービスの先行登録フォームを、Cloudflare Pages の静的ページ+Pages Functions+D1 だけで作りました。サーバーを立てず、無料の範囲で動いています。この記事では、受け口の作り方と、小さく入れておくと安心な3つの守り(同じメールの二重登録・連投・ボット)を、実際のコードで説明します。

全体の形

public/index.html   … フォーム(fetch で /api/waitlist に POST)
functions/api/waitlist.js … 受け口(Pages Functions)
schema.sql          … D1 のテーブル
wrangler.toml       … D1 を DB という名前で結び付ける

Pages Functions は functions/ の中のファイルの場所がそのまま URL になります。functions/api/waitlist.js は /api/waitlist です。

wrangler.toml で D1 を結び付けると、関数の中から env.DB で使えます。

name = "yattaru"
compatibility_date = "2026-10-01"
pages_build_output_dir = "public"

[[d1_databases]]
binding = "DB"
database_name = "yattaru-waitlist"
database_id = "<作成時に出る ID>"

テーブル

CREATE TABLE IF NOT EXISTS waitlist (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  email TEXT NOT NULL UNIQUE,
  role TEXT NOT NULL CHECK (role IN ('doer','biz')),
  niche TEXT,
  note TEXT,
  ref TEXT,           -- どの投稿から来たか(?ref=)
  ip_hash TEXT,       -- IP そのものは残さない
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_waitlist_ip ON waitlist(ip_hash, created_at);

ポイントは2つです。

  • email に UNIQUE を付け、二重登録をデータベースの側で防ぐ
  • IP アドレスは保存せず、ハッシュにして連投の判定にだけ使う。(ip_hash, created_at) に索引を張っておくと、直近の件数を数える問い合わせが軽くなる

作るときは次のコマンドです。

npx wrangler d1 execute yattaru-waitlist --remote --file schema.sql

受け口のコード

const json = (body, status = 200) =>
  new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json; charset=utf-8' } });

async function sha256(text) {
  const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
  return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, 16);
}

export async function onRequestPost({ request, env }) {
  let b;
  try { b = await request.json(); } catch { return json({ ok: false, error: '送信の形が正しくありません。' }, 400); }

  // ① ボット避け:人には見えない欄に入力があれば、受けたふりをして捨てる
  if (b.website) return json({ ok: true });

  const email = String(b.email || '').trim().toLowerCase();
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254) {
    return json({ ok: false, error: 'メールアドレスを確かめてください。' }, 400);
  }

  // ② 連投の制限:同じ IP(のハッシュ)から10分に5回まで
  const ipHash = await sha256((request.headers.get('cf-connecting-ip') || '') + env.HASH_SALT);
  const recent = await env.DB.prepare(
    "SELECT COUNT(*) AS n FROM waitlist WHERE ip_hash = ?1 AND created_at > datetime('now','-10 minutes')"
  ).bind(ipHash).first();
  if (recent && recent.n >= 5) return json({ ok: false, error: '少し時間をおいてください。' }, 429);

  // ③ 二重登録:同じメールは数えない(エラーにもしない)
  const res = await env.DB.prepare(
    `INSERT INTO waitlist (email, role, niche, note, ref, ip_hash)
     VALUES (?1, ?2, ?3, ?4, ?5, ?6)
     ON CONFLICT(email) DO NOTHING`
  ).bind(email, b.role === 'biz' ? 'biz' : 'doer',
         String(b.niche || '').slice(0, 120), String(b.note || '').slice(0, 600),
         String(b.ref || '').slice(0, 60), ipHash).run();

  return json({ ok: true, dup: res.meta.changes === 0 });
}

export function onRequest() {
  return json({ ok: false, error: 'POST だけ受け付けます。' }, 405);
}

① 見えない欄でボットを避ける

フォームに、人には見えない入力欄(ここでは website)を置きます。人は入力しませんが、フォームを機械的に埋めるボットは入れがちです。入力があったら、エラーを返さず「受けたふり」をして捨てます。エラーを返すと、ボット側が欄を見分けて回避しやすくなるためです。

<div style="position:absolute;left:-9999px" aria-hidden="true">
  <label>Website<input name="website" tabindex="-1" autocomplete="off"></label>
</div>

tabindex="-1" と aria-hidden で、キーボード操作や読み上げの利用者が迷い込まないようにしています。

② 連投を IP のハッシュで数える

cf-connecting-ip ヘッダーで送信元の IP が取れます。そのまま保存すると個人の情報が増えるので、秘密の文字列(HASH_SALT。Pages の環境変数に入れる)を足して SHA-256 にかけ、先頭16文字だけを残しています。同じ IP からの直近10分の件数を D1 で数え、5回を超えたら 429 を返します。

厳密な制限ではありません(IP を変えれば通ります)。目的は、同じ所からの連打や雑なスクリプトを止めることです。

③ 二重登録はデータベースに任せる

先に SELECT で存在を確かめてから INSERT すると、ほぼ同時の2回の送信で両方が通ることがあります。email の UNIQUE と ON CONFLICT(email) DO NOTHING に任せれば、その心配がありません。

run() の結果の meta.changes が0なら、すでに登録済みだったと分かります。画面では「登録済みです」と伝えるだけにして、エラー扱いにはしていません。

確かめ方

ローカルでは、D1 もローカルのものを使って動かせます。

npx wrangler d1 execute yattaru-waitlist --local --file schema.sql
npx wrangler pages dev --port 8788

次の4つを確かめてから公開しました。

試したこと 期待する結果
正しいメールで送る {"ok":true,"dup":false}、D1 に1行
同じメールでもう一度 {"ok":true,"dup":true}、行は増えない
形の崩れたメール 400
見えない欄に入力して送る {"ok":true} だが、行は増えない

公開したあとは、本番で1件だけテストの登録をして、D1 から消しています。

足りていないところ

  • メールアドレスの確認(届くかどうか)はしていません。先行登録の段階では、登録の手間を減らすほうを選びました
  • 連投の制限は IP 単位の簡単なものです。本格的に守るなら Cloudflare Turnstile などを足します
  • 登録した人ごとの紹介コード(ランダムな6文字)には UNIQUE を付けていますが、ぶつかったときの作り直しは入れていません。ぶつかる確率は低いものの、件数が増えたら入れる予定です

この仕組みで、AI と専門の実務家をつなぐ場「YATTARU」の先行登録を受け付けています(https://yattaru.pages.dev )。作る過程は X(@yattaru_ai)で出しています。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?