0
0

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 Workers + D1 で「登録不要・匿名・あとから変えられる」投票を 1 時間で作る

0
Posted at

『LIFE 3.0』の 12 シナリオに「望む未来」と「来そうな未来」で投票できるサイト(12futures.jp)で使っている投票の仕組みを、そのまま写経して動く最小構成に切り出しました。設計の背景は Zenn の記事に書いたので、ここは手順とコードだけです。

作るもの

  • POST /api/vote … 選択肢を送ると保存。Cookie を返す
  • 同じ Cookie からの再送信は 上書き(=あとから変えられる)
  • IP のハッシュで 新規投票は 1 日 3 回まで(連打・Cookie 消し対策)
  • GET /api/results … 集計を返す(60 秒キャッシュ)
  • 静的な index.html から fetch で叩く
  • 固定費:ドメインを使わなければ 0 円(*.workers.dev で動きます)

生の IP は保存しません。ログインもありません。

前提

  • Node.js 18 以上
  • Cloudflare アカウント(Free で可)
  • npm i -g wrangler 済み、wrangler login 済み

1. プロジェクトを作る

mkdir anon-vote && cd anon-vote
npm init -y
mkdir public src

2. D1 を作ってテーブルを切る

wrangler d1 create anon-vote
# 出力の database_id を控える

schema.sql:

CREATE TABLE IF NOT EXISTS votes (
  id TEXT PRIMARY KEY,          -- Cookie に入れる投票 ID(UUID)
  choice INTEGER NOT NULL,      -- 選択肢(1〜N)
  ip_hash TEXT NOT NULL,        -- salt 付き SHA-256
  created_at INTEGER NOT NULL,
  updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_votes_ip ON votes (ip_hash, created_at);
CREATE INDEX IF NOT EXISTS idx_votes_choice ON votes (choice);
wrangler d1 execute anon-vote --remote --file=schema.sql

3. wrangler.jsonc

{
  "name": "anon-vote",
  "main": "src/index.js",
  "compatibility_date": "2026-09-01",
  "assets": {
    "directory": "./public",
    "run_worker_first": true
  },
  "d1_databases": [
    { "binding": "DB", "database_name": "anon-vote", "database_id": "ここに database_id" }
  ],
  "vars": {
    "IP_SALT": "好きな文字列に変える",
    "CHOICES": "12"
  }
}

run_worker_first: true で、全リクエストがまず Worker を通ります。/api/ 以外は静的アセットに流します。

4. Worker 本体 src/index.js

const COOKIE = "vote_id";
const IP_LIMIT_PER_DAY = 3;

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    if (url.pathname === "/api/vote" && request.method === "POST") return handleVote(request, env, ctx);
    if (url.pathname === "/api/results" && request.method === "GET") return handleResults(request, env);
    return env.ASSETS.fetch(request); // それ以外は public/ の静的ファイル
  },
};

// ---------- utils ----------
function json(obj, status = 200, extra = {}) {
  return new Response(JSON.stringify(obj), {
    status,
    headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...extra },
  });
}
function parseCookies(request) {
  const out = {};
  for (const part of (request.headers.get("cookie") || "").split(";")) {
    const [k, ...v] = part.trim().split("=");
    if (k) out[k] = v.join("=");
  }
  return out;
}
async function sha256hex(s) {
  const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s));
  return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
function sameOrigin(request) {
  const origin = request.headers.get("origin");
  return !origin || origin === new URL(request.url).origin;
}

// ---------- POST /api/vote ----------
async function handleVote(request, env, ctx) {
  if (!sameOrigin(request)) return json({ error: "origin" }, 403);

  let body;
  try { body = await request.json(); } catch { return json({ error: "bad json" }, 400); }
  const max = Number(env.CHOICES || 12);
  const choice = Number(body.choice);
  if (!Number.isInteger(choice) || choice < 1 || choice > max) return json({ error: "choice" }, 400);

  const now = Math.floor(Date.now() / 1000);
  const cookies = parseCookies(request);

  // (1) 既存の Cookie があれば上書き
  if (cookies[COOKIE]) {
    const prev = await env.DB.prepare("SELECT choice FROM votes WHERE id = ?").bind(cookies[COOKIE]).first();
    if (prev) {
      await env.DB.prepare("UPDATE votes SET choice = ?, updated_at = ? WHERE id = ?")
        .bind(choice, now, cookies[COOKIE]).run();
      ctx.waitUntil(purgeResults(request));
      return json({ ok: true, updated: true, choice, prev: prev.choice });
    }
    // Cookie はあるが DB に無い(削除済みなど)→ 新規扱いに落ちる
  }

  // (2) 新規:IP ハッシュで回数制限
  const ip = request.headers.get("cf-connecting-ip") || "0.0.0.0";
  const ip_hash = await sha256hex(ip + "|" + env.IP_SALT);
  const recent = await env.DB.prepare(
    "SELECT COUNT(*) AS n FROM votes WHERE ip_hash = ? AND created_at > ?"
  ).bind(ip_hash, now - 86400).first();
  if (recent.n >= IP_LIMIT_PER_DAY) return json({ error: "limit" }, 429);

  const id = crypto.randomUUID();
  await env.DB.prepare(
    "INSERT INTO votes (id, choice, ip_hash, created_at, updated_at) VALUES (?, ?, ?, ?, ?)"
  ).bind(id, choice, ip_hash, now, now).run();
  ctx.waitUntil(purgeResults(request));

  const cookie = `${COOKIE}=${id}; Path=/; Max-Age=31536000; Secure; HttpOnly; SameSite=Lax`;
  return json({ ok: true, id, choice }, 200, { "set-cookie": cookie });
}

// ---------- GET /api/results ----------
async function handleResults(request, env) {
  const cache = caches.default;
  const key = new Request(new URL("/api/results", request.url).toString(), { method: "GET" });
  const hit = await cache.match(key);
  if (hit) return hit;

  const { results } = await env.DB.prepare(
    "SELECT choice, COUNT(*) AS n FROM votes GROUP BY choice ORDER BY choice"
  ).all();
  const total = results.reduce((s, r) => s + r.n, 0);
  const counts = {};
  for (const r of results) counts[r.choice] = r.n;

  // 自分の票(Cookie があれば)も返すと UI が楽
  const cookies = parseCookies(request);
  let mine = null;
  if (cookies[COOKIE]) {
    const row = await env.DB.prepare("SELECT choice FROM votes WHERE id = ?").bind(cookies[COOKIE]).first();
    mine = row ? row.choice : null;
  }

  const res = json({ total, counts, mine }, 200, { "cache-control": "public, max-age=60" });
  // 「自分の票」は人によって違うのでキャッシュには入れない
  if (!mine) await cache.put(key, res.clone());
  return res;
}
async function purgeResults(request) {
  await caches.default.delete(new Request(new URL("/api/results", request.url).toString(), { method: "GET" }));
}

ポイントは 3 つです。

  1. Cookie があれば UPDATE、無ければ INSERT。 これだけで「あとから何回でも変えられる」が成立します。
  2. IP 制限は INSERT にだけ効く。 同じ家の家族が別々に投票するのは OK、1 人が Cookie を消して積むのは 1 日 3 回で止まる。
  3. 生 IP は保存しない。 IP_SALT を変えれば過去のハッシュと突き合わせもできなくなります。

5. フロント public/index.html

<!doctype html>
<meta charset="utf-8">
<title>匿名投票</title>
<h1>あなたはどれを選ぶ?</h1>
<div id="buttons"></div>
<p id="msg"></p>
<ul id="bars"></ul>
<script>
const N = 12;
const $ = (id) => document.getElementById(id);
const btns = $("buttons");
for (let i = 1; i <= N; i++) {
  const b = document.createElement("button");
  b.textContent = "選択肢 " + i;
  b.onclick = () => vote(i);
  btns.appendChild(b);
}
async function vote(choice) {
  const r = await fetch("/api/vote", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ choice }),
    credentials: "same-origin",
  });
  const j = await r.json();
  $("msg").textContent = r.ok
    ? (j.updated ? `変更しました(${j.prev} → ${j.choice})` : `投票しました(${j.choice})`)
    : (j.error === "limit" ? "今日はこれ以上、新しい投票はできません" : "エラー: " + j.error);
  load();
}
async function load() {
  const j = await (await fetch("/api/results", { credentials: "same-origin" })).json();
  $("bars").innerHTML = "";
  for (let i = 1; i <= N; i++) {
    const n = j.counts[i] || 0;
    const pct = j.total ? Math.round(n * 100 / j.total) : 0;
    const li = document.createElement("li");
    li.textContent = `${i}: ${n} 票 (${pct}%)` + (j.mine === i ? " ← あなた" : "");
    $("bars").appendChild(li);
  }
}
load();
</script>

6. デプロイ

wrangler deploy
# → https://anon-vote.<あなたのサブドメイン>.workers.dev

ローカルで試すなら wrangler dev --remote(D1 に本番を使う)か、wrangler d1 execute anon-vote --local --file=schema.sql してから wrangler dev。

動作確認

# 新規投票(Cookie が返る)
curl -i -X POST https://anon-vote.example.workers.dev/api/vote \
  -H 'content-type: application/json' -d '{"choice":5}'

# 返ってきた Cookie を付けて再投票 → updated: true
curl -X POST https://anon-vote.example.workers.dev/api/vote \
  -H 'content-type: application/json' -H 'cookie: vote_id=<さっきの id>' -d '{"choice":10}'

# 集計
curl https://anon-vote.example.workers.dev/api/results

ハマりどころ

  • SameSite=Lax + Secure にしているので、http://localhost では Cookie が付きません。ローカル確認は wrangler dev --remote の *.workers.dev か、Secure を一時的に外してください。
  • キャッシュと「自分の票」。 /api/results を Cache API で 60 秒キャッシュしていますが、mine(自分の票)は人ごとに違うので、Cookie がある応答はキャッシュに入れていません。投票直後に古い集計が見えるのが嫌なら、投票時に purgeResults() で消しています(上のコードに入っています)。
  • cf-connecting-ip は Cloudflare 経由のときだけ入ります。wrangler dev(ローカル)では 0.0.0.0 扱いになるので、制限のテストは --remote で。
  • Origin チェックは「自分のページから叩かれたか」の最低限の確認です。CSRF の完全対策ではないので、必要ならトークンを足してください。

ここから先(本番でやっていること)

本番サイトでは、これに加えて次を入れています。詳しくは Zenn の記事に。

  • 投票の変更履歴を別テーブル(vote_events)に残し、流入元ドメイン・UTM・国・端末種別と一緒に保存(URL は保存しない)
  • 2 問(望む/来そう)を同時に投票、結果ページを OGP 付きで生成して X に貼れるようにする
  • 読者コメントの受け口と、Cloudflare Access で守った承認画面

まとめ

Worker 1 ファイル、テーブル 1 つ、HTML 1 枚で「登録不要・匿名・あとから変えられる・連打できない」投票ができます。厳密な 1 人 1 票ではありませんが、趣味のサイトの「気軽に選んでもらう」用途には、これで十分でした。

実際に動いているものはこちら:12の未来へ、私たちはどこに向かうのか(登録不要・匿名・あとから何回でも変えられます)

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?