Skip to content
アルパカ プレス | IT・AI・ゲーム・動画制作ブログ ロゴ
Go back

Astroブログにコメント機能を追加した|Cloudflare Workers・D1・Turnstileで作る承認制コメント欄

このAlpaca Pressに、記事ごとのコメント機能を追加しました。

Astroで作った静的ブログなので、記事を表示するだけならHTMLを配信すれば完結します。しかし、コメントを受け付けるとなると、投稿API、保存先、スパム対策、管理画面、承認後の公開処理が必要です。

今回は、次のCloudflareサービスを組み合わせました。

  • Cloudflare Workers:コメントAPIと認証処理
  • D1:コメントの保存
  • Turnstile:自動投稿への対策
  • Cloudflare Access:管理画面の保護
  • Email Service:新着コメントの通知

結論からいうと、Astroの静的配信を維持したまま、コメントの投稿から承認、公開までを同じCloudflare Workerにまとめられました。

外部のコメントサービスをページへ埋め込む形ではなく、ブログのデザインや運用方法に合わせて自分で管理できるコメント欄です。

なお、本文中のCloudflareサービス名と仕様は、2026年9月16日時点の公式ドキュメントで確認しています。

今回作ったコメント機能

記事の下に、公開済みコメントの一覧と投稿フォームを表示します。

読者が入力する項目は、投稿者名とコメント本文だけです。投稿者名は投稿成功後にブラウザへ保存し、次回から自動入力されるようにしました。

コメントは投稿直後には公開しません。一度「承認待ち」としてD1へ保存し、管理者が内容を確認してから公開します。

コメントが投稿されてから承認・公開されるまでの流れ

処理の流れは次のとおりです。

  1. 読者が記事ページからコメントを投稿する
  2. Workerが入力内容とTurnstileトークンを検証する
  3. 問題がなければ、D1へ承認待ちとして保存する
  4. 管理者へ新着コメントをメールで通知する
  5. Cloudflare Accessで保護した管理画面から内容を確認する
  6. 承認したコメントだけを記事ページへ表示する

投稿と公開を分けたのは、コメント欄を作るなら最初から管理できる状態にしておきたかったためです。せっかく交流できる場所を作っても、スパム対応で疲れて閉じることになったら悲しいですからね(笑)

Astroの静的ページとWorkerを同居させる

このブログはAstroでビルドし、生成されたdistをCloudflare Workers Static Assetsで配信しています。

Cloudflare Workersでは、静的ファイルとWorkerスクリプトを同じデプロイに含められます。run_worker_firstを有効にすると、静的ファイルを返す前にWorkerでリクエストを処理できます。

{
  "main": "worker/index.ts",
  "assets": {
    "directory": "./dist",
    "binding": "ASSETS",
    "run_worker_first": true
  }
}

Worker側では、コメントAPIと管理機能に該当するリクエストだけを処理します。それ以外はASSETS bindingへ渡し、通常のAstroサイトとして配信します。

if (path === "/api/comments") {
  return handleComments(request);
}

return env.ASSETS.fetch(request);

この構成なら、ブログ本体とコメントAPIを別ドメインへ分ける必要がありません。フロント側からは同一オリジンのAPIとして呼び出せるので、構成も分かりやすくなりました。

Cloudflareの公式ドキュメントにも、Static AssetsとWorkerスクリプトを組み合わせる方法、run_worker_firstによる処理順の変更が説明されています。

全記事へ共通のコメントコンポーネントを表示する

Astro側では、コメント欄をBlogComments.astroというコンポーネントにまとめました。

記事詳細の共通レイアウトから呼び出しているため、各Markdownへコメントフォームを追加する必要はありません。既存記事を含め、すべての記事で同じコメント機能を利用できます。

<BlogComments contentId={commentContentId} />

contentIdには公開記事のパスを使います。

たとえば記事URLが次の場合、

/posts/tech/example-article/

コメント用のIDは次のような形式になります。

tech/example-article

記事ごとにIDを持たせることで、同じAPIとD1テーブルを使いながら、表示するコメントを分けられます。

さらにWorker側で、そのIDに対応する公開記事が本当に存在するか確認しています。形式が正しいだけの架空の記事IDを送っても、コメントは保存されません。

D1には「承認待ち」で保存する

コメントの保存先にはCloudflare D1を使いました。

D1はWorkerへbindingとして接続でき、Workerからプリペアドステートメントを使ってSQLを実行できます。コメントAPIでは、投稿内容を検証したあとにD1へ保存します。

コメントには、次のような状態を持たせています。

状態用途
pending承認待ち
published公開済み
rejected非承認
spamスパム扱い
deleted削除済み

読者からの新規投稿は、必ずpendingから始まります。公開APIが返すのはpublishedのコメントだけです。

つまり、ブラウザから状態をpublishedに書き換えて送ったとしても、その値を信用しません。公開状態を変更できるのは、認証された管理操作だけです。

D1とWorkerの接続方法は、公式ドキュメントのbinding構成に沿っています。

TurnstileはWorker側でも検証する

投稿フォームにはCloudflare Turnstileを設置しました。

ここで大切なのは、ブラウザにTurnstileウィジェットを表示するだけでは不十分という点です。

投稿時に取得したトークンをWorkerへ送り、WorkerからCloudflareのSiteverify APIへ問い合わせます。さらに今回の実装では、検証結果のsuccessだけでなく、想定したホスト名とactionが一致することも確認しています。

return (
  result.success === true &&
  result.hostname === expectedHostname &&
  result.action === expectedAction
);

検証に必要なsecretはWorkerのsecretとして管理し、ブラウザ側へは出しません。Turnstileの公式ドキュメントでも、Siteverifyによるサーバー側検証は必須とされています。

Turnstileだけに頼らず、入力も細かく検証する

Turnstileを通過したからといって、投稿内容まで正しいとは限りません。

Workerでは、次の内容を別々に検証しています。

  • 許可していないキーが含まれていないか
  • 投稿者名と本文が空ではないか
  • 文字数やURL数が上限を超えていないか
  • 記事IDの形式が正しいか
  • 対象の記事が実際に公開されているか
  • コメント種別と内部カテゴリの組み合わせが正しいか
  • 投稿リクエストが大きすぎないか

エラー時は内部の判定結果を細かく返さず、投稿エラーとしてまとめています。入力条件を順番に探られにくくするためです。

また、通信の再試行で同じコメントが二重保存されないよう、投稿ごとにidempotency keyも付けました。同じキーのリクエストが再送された場合は、新しいコメントを増やさず、既存の結果を返します。

レート制限では生のIPアドレスを保存しない

短時間に大量の投稿が送られた場合に備えて、WorkersのRate Limiting bindingも利用しています。

この実装では、CF-Connecting-IPをそのまま保存したりキーとして外部へ出したりせず、Worker内で秘密鍵付きHMACへ変換してからレート制限のキーにしています。

const rateLimitKey = await createHmacKey(requestIp, env.IP_HASH_SECRET);
const result = await env.COMMENT_RATE_LIMITER.limit({ key: rateLimitKey });

ただし、IPベースの制限は完全な利用者識別ではありません。同じ会社や家庭、モバイル回線などで複数人が同じIPを共有する可能性があります。今回は投稿フォームへの軽量な多層防御として使い、Turnstileや承認制と組み合わせています。

Rate Limiting APIの性質やキー設計上の注意点は、公式ドキュメントでも説明されています。

コメント本文はHTMLとして挿入しない

公開済みコメントを表示するときは、本文をinnerHTMLへ渡していません。

const body = document.createElement("p");
body.textContent = String(comment.body ?? "");

textContentとして挿入することで、コメントに<script>や<img onerror=...>のような文字列が含まれていても、HTMLとして実行されず、そのまま文字として表示されます。

もちろん、表示時の対策だけでなく、投稿時の長さや形式もWorker側で確認します。フロント側のmaxlengthは入力の補助であり、セキュリティ境界にはしていません。

管理画面はCloudflare Accessで保護する

承認、非承認、スパム判定、削除といった管理操作は、一般公開の投稿APIと分けています。

管理画面と管理APIの手前にはCloudflare Accessを設定しました。さらにWorker側でもAccessが付与したJWTについて、署名、issuer、audience、有効期限を検証します。

Accessのヘッダーが存在するだけで管理者扱いにはしません。Cloudflareの公開鍵を使って署名を確認し、対象アプリ向けに発行された有効なトークンであることを確認できなければ拒否します。

また、状態変更を伴う管理リクエストではOriginも検証しています。

Cloudflareの公式ドキュメントでも、Accessで保護したWorker側でJWTの署名とclaimsを検証する方法が案内されています。

新しいコメントはメールで通知する

コメントを毎回管理画面へ見に行くのは現実的ではないため、新規投稿が保存されたら管理者へメールを送ります。

メール送信にはCloudflare Email ServiceのWorkers bindingを使いました。D1への保存を完了してからバックグラウンドで通知し、通知に失敗しても、保存済みのコメント投稿まで失敗扱いにはしません。

通知先は確認済みアドレスを使い、実際の宛先はsecretとして管理しています。

コメント取得に失敗しても記事は読めるようにする

コメント機能は記事の付加機能です。APIやネットワークに問題が起きたとき、記事本文まで読めなくなるのは避けたいところです。

そのため、コメント取得に失敗した場合は、コメント欄にエラーメッセージを表示するだけにしました。Astroで生成した記事本文はそのまま読めます。

公開コメントが増えた場合に備えてページングも用意し、「以前のコメントを読み込む」ボタンから続きを取得します。追加読み込みだけ失敗した場合は、すでに表示できているコメントを残したまま再試行できます。

投稿者名は投稿成功後にlocalStorageへ保存しますが、ブラウザの設定などで保存できなくても投稿自体は成功扱いにしています。

必須ではない機能が失敗しても、記事閲覧やコメント投稿の本体を巻き込まないようにしました。

静的ブログのまま、読者とやり取りできる場所ができた

Astroの静的ブログへコメント機能を追加するために、ブログ全体をSSRへ変更する必要はありませんでした。

静的な記事はWorkers Static Assetsから配信し、動的な部分だけをWorkerのAPIへ任せる。コメントはD1へ保存し、Turnstile、レート制限、承認制、Access認証を重ねる。この分け方は、個人ブログにも扱いやすい構成だと感じています。

特に気に入っているのは、コメントAPIに問題が起きても記事そのものは読める点です。コメントは大切ですが、ブログの主役はあくまで記事です。この優先順位は崩さずに実装できました。

今後は実際の運用を見ながら、スパムの傾向や承認作業の負担を確認し、必要な部分だけ調整していく予定です。

記事の感想や「ここをもう少し詳しく知りたい」といった内容があれば、さっそく下のコメント欄から送ってみてください!


関連記事

コメント

記事の感想や質問をお寄せください。投稿は管理者の承認後に公開されます。

公開コメントを読み込み中…