『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 つです。
- Cookie があれば UPDATE、無ければ INSERT。 これだけで「あとから何回でも変えられる」が成立します。
- IP 制限は INSERT にだけ効く。 同じ家の家族が別々に投票するのは OK、1 人が Cookie を消して積むのは 1 日 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の未来へ、私たちはどこに向かうのか(登録不要・匿名・あとから何回でも変えられます)