この度、当ブログをWordpress on 自宅鯖 through Cloudflare Tunnelから、Astro on Cloudflare Pagesに移行しました。
その際、AstroおよびテンプレートFuwariにコメント機能がなかったので、Cloudflare D1を利用したシンプルなコメント機能を作りました。
本記事はその備忘録です。
CAUTION筆者は素人です。したがって間違った点が多々あると思いますので、お気を付けください。なお具体的な修正コメント歓迎いたします。
はじめに
Astroブログ(および静的ブログ)にコメント機能を付けるための既存のソリューションについて検討しました。 代表的なものにはDisqusやgiscusなどがあると思いますが、それぞれ以下の理由で採用しませんでした。
- Disqus:ごちゃついた見た目が嫌い。採用サイトを見たことがあるが使いづらい。
- giscus:Githubログイン必須で匿名コメント機能がない。特に日本ではわざわざログインしてまでコメントしようという人はまずいないと思われる。
というわけで目的に合ったものがなかったため、自分で作ることにしました。
せっかくサイトをCloudflare Pagesでのホストにしたので、コメント機能もサーバーレスで構築したいところ。
CloudflareにはD1というサーバーレスDBがあるので、Cloudflare Workersと組み合わせてうまいことすれば作れそうと思いました。
システムの概要
コメントシステムはAstroから独立したAPIとします。
したがってサイト側がCloudflare意外のサービスでホストされていたとしても利用できることになります。
都合がよいことにCloudflareのチュートリアルにD1を使ってコメントAPIを作るというものがあるので、これを改変して利用します。
Astroブログは、ビルド時にAPIからコメント取得して、コメントもページの一部として静的にページ生成します。
動作の流れ
コメントが投稿された際の動作は以下のような流れになります。
- AstroブログのフォームからコメントAPIにjsonでPOST
- コメントAPIは受信内容をバリデーションしてDBに格納、その後Astroブログのデプロイフックを叩く
- AstroブログはコメントAPIからGETでコメントを取得、コメントをページの一部として静的にビルド
コメントAPIの構築
schema.sql
テーブルの構造を変更。
DROP TABLE IF EXISTS comments; CREATE TABLE IF NOT EXISTS comments ( id integer PRIMARY KEY AUTOINCREMENT, author text NOT NULL DEFAULT 'Anonymous', body text NOT NULL, post_slug text NOT NULL, post_date text NOT NULL DEFAULT CURRENT_DATE ); CREATE INDEX idx_comments_post_slug ON comments (post_slug);
index.js
コメント取得はslugごとではなく、全件一括でJSONを返すようにします。
app.get('/api/<任意のパス:コメント取得用>', async (c) => { const { results } = await c.env.DB.prepare( ` select * from comments ` ).all(); return c.json(results); });
投稿時もAPIエンドポイントのパスにslugを含めるのをやめ、jsonの中にpost_slugを含めてPOSTにするように変更。
app.post('/api/<任意のパス:コメント投稿用>'>, async (c) => { var { author, body, slug } = await c.req.json(); /* バリデーションとか実施 */ // 改行を<br>に置換しておく body = body.replace(/(?:\r\n|\r|\n)/g, '<br>'); const { success } = await c.env.DB.prepare( ` insert into comments (author, body, post_slug) values (?, ?, ?) ` ) .bind(author, body, slug) .run(); // Cloudflare Pages用 // Deploy Hookを叩く await fetch('<Deploy HookのURL>', { method: 'POST', }); if (success) { c.status(201); return c.text('Created'); } else { c.status(500); return c.text('Something went wrong'); } });
これでコメントAPIの方はひとまず完成。
Astroコードの改変
src/library/comments.ts
まずコメント取得用のライブラリを作成。
記事ごとにfetchしてるとビルド時間に悪影響があることが容易に予想できるため、一括で取得した後componentからの要求に応じてslugでフィルターして返す。
const response = await fetch( '<コメント取得用APIエンドポイントのURLまたはパス>', ) if (!response.ok) throw new Error('[Comments] Comments cannot be fetched.') const comments = await response.json() console.log(`[Comments] Fetched ${Object.keys(comments).length} comments.`) export type comment = { id: number author: string body: string post_slug: string post_date: string } export function getComments(slug: string): comment[] { return comments.filter((comment: comment) => { return comment.post_slug === slug }) }
src/components/comments/D1-comments.astro
次にコメントのAstroコンポーネントを作成。
コメントはサニタイズ(brのみ許可)後URLをリンクに変換しています。
--- import { getComments } from '@/library/comments' import type { comment } from '@/library/comments' import sanitizeHTML from 'sanitize-html' const { slug } = Astro.props const comments = getComments(slug) const hasComment = comments.length > 0 function replaceURLWithLink(text: string) { const urlRegex = /https?:\/\/[-_.!~*\'()a-zA-Z0-9;\/?:\@&=+\$,%#\u3000-\u30FE\u4E00-\u9FA0\uFF01-\uFFE3]+/g return text.replace( urlRegex, url => `<a href="${url}" target="_blank">${url}</a>`, ) } --- <div id="comments_container"> <div id="comment_title"> Comments </div> { hasComment && comments.map( (comment: comment) => ( <div id="comment"> <div id="comment_header"> <div id="comment_author"> {comment.author} </div> <div id="comment_date"> {comment.post_date} </div> </div> <div id="comment_body" set:html={replaceURLWithLink(sanitizeHTML(comment.body, { allowedTags: ["br"], }))} ></div> </div> ), ) } { !hasComment && ( <p> No comments yet. </p> ) } <form id="comment_form" action="<投稿用APIエンドポイントのURLまたはパス>"> <input type="text" name="author" placeholder="Name" maxlength="20" /><br> <textarea name="body" placeholder="Message" maxlength="1000" required ></textarea><br> <input type="hidden" name="slug" value={slug} /> <button id="submit" type="button"> Submit </button> </form> </div> <script> const form = document.forms[0]; const button = document.getElementById("submit") as HTMLInputElement; button?.addEventListener("click", async function () { button.disabled = true; const formData = new FormData(form); if (formData.get("body") === "") { button.disabled = false; return; } const data = JSON.stringify(Object.fromEntries(formData)); const res = await fetch(form.action, { method: "POST", headers: { "Content-Type": "application/json", }, body: data, }); if (res.ok) { form.body.value = ""; } button.disabled = false; }); </script>
src/pages/posts/[…slug].astro
あとは、[…slug].astroから作成したコンポーネントを呼び出せばOK。
<D1Comments slug={entry.slug} />
一応これで超シンプルなコメント機能が実装できました。
ただこのままだとAPIエンドポイントが脆弱なので、適宜保護する必要があります。 例えばCloudflare Turnstileのpre-clearanceモードで投稿時のfetch()リクエストに対してマネージドチャレンジをかけられるようにする、など。
なおこの方法の場合、追加するコードのうち1つ目のscriptタグにはis:inlineを指定しないとうまく動作しませんでした。
また、/api/*にマネージドチャレンジをかけるとビルド時のコメントフェッチも失敗するので、WAFのカスタムルールでコメントフェッチ時はマネージドチャレンジをスキップするような設定も必要かもです。 たとえば任意のリクエストヘッダとかクッキーで認証キーを指定するとか。