前回の記事で、ブログサービスの note で練習の話を書き始めたことを書きました。note では、記事の「スキ」(いいねのボタン)を押すと、書き手が選んだお礼の画像とメッセージが吹き出しで出ます。note のお礼用に、マスコットの Friko が BPM 120 のリズムに乗って跳ねる GIF アニメを作りました。
note のスキは、note にログインしていなくても押せます。読んだ人が、アカウントを作らずに「よかった」と伝えられます。
この開発ブログでも、同じことができるようにしました。記事の終わりにいいねのボタンを置いています。サインインしていなくても押せて、もう一度押すと取り消せます。

- いいねのボタン
押すと、note と同じお礼が出る
いいねを押すと、ボタンの上に吹き出しが出ます。中身は note のスキのお礼と同じ GIF です。

添える文は、GIF のテンポに掛けて「いいね、BPM 120 で受け取りました!ありがとうございます ♪」にしました。
いいねの数は BPM で表す
いいねの数は「12 BPM」のように、BPM の単位で表します。いいね 1 回で 1 BPM です。押されるほど記事のテンポが上がる、という遊びの見立てで、テンポを測っているわけではありません。いいねは、日本語と英語の記事を合わせて数えます。
同じ人が何度も押せないようにする
サインインしていなくても押せるようにすると、同じ人が何度も押せないようにする必要があります。note も、ログインなしのスキを始めたときのお知らせに「連続して同じ人がスキすることはできない仕様」と書いています(note 公式のお知らせ)。ただ、どう見分けているかは書かれていません。そこで、このブログでは次のようにしました。
- サインインしている人は、アカウントで見分ける
- サインインしていない人は、ブラウザに保存したランダムな ID(Cookie)で見分ける
データベースには「記事 × 押した人」で 1 行を作り、同じ組み合わせの行を 2 つ作れないようにしています。データベースを扱うライブラリ Prisma のスキーマから、要点の行だけを抜き出すと次のとおりです。
model BlogPostLike {
postSlug String
userId String?
guestId String?
@@unique([postSlug, userId])
@@unique([postSlug, guestId])
}userId(アカウント)と guestId(ブラウザに保存した ID)は、どちらか一方だけが入るようにしています(データベースの CHECK 制約)。1 回のいいねを、アカウントとブラウザの両方で数えないためです。@@unique は null どうしを重なりとみなさないので、userId が空のゲストの行がいくつあっても、userId の側で止められることはありません。
ただし、Cookie を消したときと、サインインせずに押したあとでサインインしたときは、別の人として数えるので、もう一度押せます。いいねは気軽な反応なので、そこまでは止めないことにしました。代わりに、押す・取り消すの回数に IP アドレスごとの上限(1 時間に 60 回)を設け、大量の水増しを止めています。
ID は、いいねを押したときに保存する
サインインしていない人を見分ける ID は、Metronome.fm のアプリがもとから使っているものと同じです。アプリは、サインインしていない人の 1 日の再生時間を数えるために、アプリのページを開いた人に、この ID を Cookie で保存しています。保存するのは、リクエストがページに届く前に動く処理(Next.js の proxy。以前の middleware)です。いいねでは、この ID の見分け方だけを使い回し、保存するタイミングを変えました。
ブログでは、この ID を、記事を開いたときではなく、いいねを初めて押したときに保存します。記事を読むだけの人に、人を見分けるための Cookie を付けないためです。記事のページには、言語を覚える Cookie(NEXT_LOCALE)も付きますが、これは表示の言語の設定で、人を見分けるものではありません。
ただし、記事を読むだけでも ID が保存されることがあります。記事の下にはアプリを開くリンクがあり、Next.js はリンクが画面に入ると、リンク先を前もって読み込みます(プリフェッチ)。このプリフェッチもアプリのページへのリクエストなので、proxy が応答に ID の Cookie を付け、ブラウザがそれを保存します。プリフェッチのときだけ付けないようにしようとしましたが、Next.js はプリフェッチかどうかを示すヘッダーを、proxy に渡す前に消していて、見分けられませんでした。アプリに移るときの速さを優先して、プリフェッチはそのまま残しています。
表示の速さは、理由にならなかった
はじめは、表示の速さも、記事を開いたときに ID を保存しない理由に挙げていました。記事のページは前もって作っておき、Vercel の CDN(世界各地にある配信の拠点)から返しています。いいねの数と自分が押したかどうかは、ページを開いたあとにブラウザから読みに行き、ページそのものは誰が開いても同じ中身にしています。CDN がキャッシュできる応答の条件のひとつが「set-cookie ヘッダーを含まない」ことなので(Vercel のドキュメント)、記事を開いたときに Cookie を付けると、ページがキャッシュされなくなると考えたからです。
ところが、公開したあとに本番で確かめると、NEXT_LOCALE の Cookie が付いた記事のページが、CDN のキャッシュから返っていました(x-vercel-cache: HIT)。キャッシュから返っているのに、この Cookie の有効期限の時刻はリクエストのたびに変わっていました。CDN がキャッシュしているのはページの本体で、proxy がそのあと応答ごとに Cookie を付け足しているためです。ID も proxy で付けているので、記事を開いたときに保存しても、表示は遅くならなかったはずです。
ほかのサイトから押させない
ほかのサイトにフォームや JavaScript を仕込み、読んだ人のブラウザから、こちらにいいねのリクエストを送らせることもできてしまいます(いわゆる CSRF)。読んだ人のブラウザには ID の Cookie があるので、その人のいいねとして数えられてしまいます。いいねの API では、次の 2 つでこれを防いでいます。
- JSON で送られたリクエストだけを受け付ける。フォームは JSON を送れないので、ここで止まる。JavaScript から JSON を送ろうとすると、ブラウザが先に送ってよいかを問い合わせ(CORS)、こちらが許していないので止まる
- ブラウザが付ける
Sec-Fetch-Siteヘッダーを見て、同じオリジン(same-origin)以外からのリクエストを断る
const isSameOriginJsonRequest = (request: NextRequest) => {
const contentType = request.headers.get("content-type") ?? "";
const fetchSite = request.headers.get("sec-fetch-site");
return (
contentType.startsWith("application/json") &&
(fetchSite === null || fetchSite === "same-origin")
);
};Sec-Fetch-Site がないリクエストを通しているのは、送ってくるのがブラウザ以外か古いブラウザだからです。ブラウザ以外は読んだ人の Cookie を使えず、古いブラウザからでも、ほかのサイトの JSON は CORS で止まります。
記事を読んで面白かったら、記事の終わりのいいねを押してもらえるとうれしいです。note の記事でスキを押すと、同じ Friko がお礼を言いに出てきます。