Metronome.fm に、練習のことを話し合うフォーラムをリリースしました。サインインすれば誰でも書けるので、宣伝や荒らしの投稿が来ることも考えておく必要があります。
投稿は保存する前に AI のモデルで判定し、スパムや荒らしと判断したものは載せないようにしています。ただ、判定をすり抜ける投稿もあります。そうした投稿は、読んだ人に通報してもらい、管理者が対応します。
運営は 1 人なので、通報が来てもすぐに対応できるとは限りません。そこで、通報が集まった投稿は人の判断を待たずに隠すことにしました。この記事では、自動で隠す決まりと、誰を管理者にするかの決め方を書きます。あわせて、隠した投稿の本文をブラウザに送らないようにしたことと、公開前のセキュリティの点検で見つけて直した 2 つの弱いところも書きます。
投稿を通報する
他人の投稿は、右上の「⋯」のメニューから通報できます。

- 投稿の右上の「⋯」のボタン
- 「通報する」
通報するときは、「宣伝・スパム」「攻撃的・荒らし」「フォーラムと関係ない」「その他」の 4 つから理由を選びます。補足のひとこと(500 字まで)も添えられます。

- 理由の選択肢
- 補足(任意)の欄
通報できるのは 1 つの投稿に 1 人 1 回です。自分の投稿と、すでに隠れている投稿は通報できません。通報の連打を防ぐため、1 人が通報できるのは 1 時間に 20 回までにしています。
別々の 3 人の通報で自動で隠す
別々の 3 人から通報された投稿は、その時点で自動で隠します。管理者が見るまで待つと、そのあいだ荒らしの投稿が見えたままになるためです。1 人の通報だけで隠さないのは、勘違いや嫌がらせの通報もありうるからです。
ただし、3 人に数えるのは、前からフォーラムに書いている人の通報だけです。この条件は、公開前の点検で見つけた弱いところを直すために加えました。詳しくは後の節で書きます。
隠した投稿は、本文の代わりに「この投稿は通報が多かったため、非表示になっています。」と出します。

- 本文の代わりに出るお知らせ
隠したのがスレッドの最初の投稿なら、スレッドごと隠します。スレッドの一覧にも出さず、スレッドを開いても題は「非表示のスレッド」と出します。題に荒らしの文が書かれていることもあるためです。
隠された投稿は、書いた本人も編集できません。本文を書き換えて、もう一度見せられるようにすることはできません。表示に戻せるのは管理者だけです。
同時に何人かが通報しても、隠すのは一度だけです。データベースを書き換えるときに、まだ隠れていない投稿だけを対象にしているためです。
管理者にメールで知らせる
投稿が初めて通報されたときと、自動で隠したときに、管理者へメールを送ります。通報のたびに送ると、1 つの投稿で何通も届いてしまうためです。
メールには、スレッドの題、投稿の ID、まだ対応していない通報の数、最後の通報の理由と、管理画面へのリンクを入れています。通報に添えられたひとことは入れません。ひとことは誰でも自由に書けるので、入れると、管理者宛てのメールに誰でも好きな文を載せられてしまうからです。ひとことは管理画面で読みます。
メールを送れなかったときも、通報そのものは受け付けます。通報はデータベースに残るので、管理画面で見られます。
誰が管理者かは、確認済みのメールアドレスで決める
管理者は、サーバーの環境変数 FORUM_ADMIN_EMAILS に書いたメールアドレスで決めています。サインインしている人の Clerk(サインインに使っているサービス)のアカウントにそのアドレスが登録されていて、しかも確認済みなら管理者です。
export const isForumAdmin = async (): Promise<boolean> => {
const admins = parseForumAdminEmails();
if (admins.length === 0) return false;
const user = await currentUser();
return (
user?.emailAddresses.some(
(address) =>
// 確認済みのアドレスだけを見る
address.verification?.status === "verified" &&
admins.includes(address.emailAddress.trim().toLowerCase()),
) ?? false
);
};確認済みかを見ているのは、管理者のアドレスを自分のアカウントに登録するだけで管理者になれないようにするためです。メールアドレスは誰でも自分のアカウントに登録できますが、確認のコードはそのアドレスの受信箱に届くので、持ち主でなければ確認済みにできません。
管理者でない人が管理画面(/forum/admin)を開くと、ページが見つからないとき(404)と同じ画面を出します。管理画面があること自体もわからないようにしています。
管理画面でできること
管理画面には、まだ片づけていない通報がある投稿と、隠した投稿を並べます。「通報を片づける」は、通報を対応済みにする操作です。片づけた通報は、通報の数にも、自動で隠すときの数にも入りません。
管理画面では、投稿ごとに本文、通報の理由とひとこと、通報の数が見えます。

- 通報で自動で隠れたことと、通報の数
- 通報の理由と、添えられたひとこと
- 隠れている投稿に出る、表示に戻す・削除するのボタン
投稿ごとにできることは次のとおりです。
操作 | どうなるか |
|---|---|
表示に戻す | 隠した投稿を戻し、通報を片づける |
非表示にする | 管理者の判断で隠し、通報を片づける |
通報を片づける | 投稿は隠さず、通報を対応済みにする |
削除する | 本文の代わりに「この投稿は削除されました。」と出し、通報を片づける |
隠れている投稿には「表示に戻す」と「削除する」が、隠れていない投稿には「非表示にする」「通報を片づける」「削除する」が出ます。
表示に戻すときにも通報を片づけるのは、戻した直後に同じ通報の数でまた自動で隠れないようにするためです。
削除した投稿は、投稿に貼った画像も保存先から消します。スレッドの最初の投稿を削除すると、スレッドごと消えます。
管理者は、スレッドの画面からも同じ操作ができます。管理者がスレッドを開くと、隠した投稿の本文が「非表示の投稿です(管理者にだけ見えています)。」の注記と一緒に出ます。

- 管理者にだけ本文が見えていることの注記
隠した投稿の本文はブラウザに送らない
隠した投稿や削除した投稿の本文は、管理者でない人のブラウザにはそもそも送りません。画面で見えないようにするだけでは、本文を読めてしまうためです。
スレッドの画面は、サーバーでデータベースを読み、投稿を表示する部品(クライアントコンポーネント)に渡しています。この部品に渡した値は、ページと一緒にブラウザへ送られるデータ(RSC のペイロード)に入ります。本文を渡したうえで画面で伏せても、ページのソースや開発者ツールで本文が読めてしまいます。
そのため、部品に渡す前のサーバーの処理で投稿の状態を決め、見せない投稿は、本文と、投稿に付いたリアクションを空にしています。
const state: ForumPostState = post.deletedAt
? "deleted"
: post.hiddenAt || topic.hiddenAt
? isAdmin
? "hiddenForAdmin"
: "hidden"
: "visible";
// 本文を返すのは、ふつうの投稿と、管理者が見る隠した投稿だけ
const showBody = state === "visible" || state === "hiddenForAdmin";
return {
id: post.id,
body: showBody ? post.body : "",
reactions: showBody ? summarizeReactions(post.reactions, viewerId) : [],
state,
// …
};隠したスレッドは、題と、スレッドに添付したメトロノームの設定も同じように空にして渡します。画面の部品は投稿の状態(state)を見て、「非表示になっています」や「削除されました」と表示します。
点検で見つけて直した 2 つの弱いところ
ここまでを作ったあと、公開の前に、Claude Code のセキュリティ点検のスキルで、変更したコードを点検しました。通報の機能では、深刻さが「中」の指摘が 2 つありました。
アカウントを 3 つ作れば、どの投稿でも隠せた
はじめは、通報した人が誰でも 3 人そろえば隠していました。これでは、アカウントを 3 つ作れば、どの投稿でも隠せてしまいます。
そこで、自動で隠す数には、前からフォーラムに書いている人の通報だけを数えるようにしました。「24 時間より前に書いた投稿があり、その投稿が削除も非表示もされていない人」です。
const trustedReports = await prisma.forumPostReport.count({
where: {
postId: post.id,
resolvedAt: null,
// 24 時間より前の、消されても隠されてもいない投稿がある人の通報だけを数える
reporter: {
forumPosts: {
some: {
deletedAt: null,
hiddenAt: null,
createdAt: {
lt: new Date(Date.now() - FORUM_TRUSTED_REPORTER_MIN_POST_AGE_MS),
},
},
},
},
},
});
const shouldHide = trustedReports >= FORUM_AUTO_HIDE_REPORT_COUNT; // 3作ったばかりのアカウントからの通報も受け付けて DB に残し、管理画面に出します。最初の通報ならメールも送ります。違うのは、自動で隠すときの数に入れないことだけです。アカウントを量産して投稿を隠そうとすると、それぞれのアカウントで、AI の判定でスパムとされない投稿を書き、1 日待つ必要があります。
通報のダイアログの説明も、「前からフォーラムに書き込んでいる別々の 3 人から通報されると、自動で非表示になります。」に直しました。作ったばかりのアカウントの通報だけでは隠れないことは、ブラウザを動かす自動テスト(E2E テスト)で確かめています。
管理者へのメールで受信箱を埋められた
管理者へのメールは、どの投稿でも、最初に通報されたときに送ります。1 人が通報できる数には 1 時間 20 回の上限がありますが、アカウントを増やして別々の投稿を通報すれば、上限に当たらずにメールを送らせ続けられます。そうなると、管理者の受信箱と、メールの送信サービス(Resend)で送れる通数の枠を埋められてしまいます。
そこで、管理者へのメールには、アプリ全体で 1 時間に 20 通の上限を付けました。上限を超えたらメールは送らず、警告のログだけを残します。通報は DB に残っているので、管理画面を開けば見られます。
const { success } = await getHourlyGlobalForumAdminNotifyRatelimit().limit(
FORUM_ADMIN_NOTIFY_RATE_LIMIT_IDENTIFIER, // 送る相手は管理者だけなので、識別子は 1 つに固定
);
if (!success) {
logServerWarning("Skipped notifying forum admins: hourly global limit reached");
return;
}残っている弱いところ
たくさんのアカウントから投稿を大量に書かれることへの備えは、1 人あたりの投稿の上限(1 時間 20 回)と、保存の前の AI の判定だけです。判定でスパムとされない程度の投稿を、たくさんのアカウントから書かれると止められません。そうなったら管理画面から 1 件ずつ削除することになります。