feat(comments) : filter abusive language and prevent spam
CI / Verify (push) Successful in 1m41s
CI / Build immutable images and deploy (push) Successful in 2m30s

This commit is contained in:
2026-10-08 04:45:45 +07:00 Unverified
parent 0f0e983bcb
commit 20c52fac04
8 changed files with 235 additions and 44 deletions
+10
View File
@@ -208,6 +208,16 @@ selected guide (or all guides) read for that admin. Hiding a root also hides its
are recorded in the activity log. Comment reads and legacy attachment redirects
are not cached; guide content caches are independent of discussions.
Comment writes (including replies and edits) are limited per account across all
guides to one every 5 seconds, 5 per minute, and 30 per hour using shared Redis.
New text comments cannot repeat the same normalized text within 5 minutes;
failed uploads or saves release that duplicate reservation. Edits and image-only
comments still use the write limits. Before uploading images or saving, the server
rejects a focused Thai/English abusive-term list, common English spelling
obfuscations, more than 3 links, 20 identical consecutive characters, and a word
or short phrase repeated 8 times. The rules live in `lib/comments/moderation.ts`;
they are heuristic text filters, not image moderation or an automated review queue.
The comment migration must run before deploying these pages. For PostgreSQL
integration verification, point `DATABASE_INTEGRATION_URL` at a migrated test or
development database and run `bun run test tests/comments.integration.test.ts`.
+3
View File
@@ -1,6 +1,9 @@
export const COMMENT_ERRORS: Record<string, string> = {
"sign-in-required": "กรุณาเข้าสู่ระบบเพื่อแสดงความคิดเห็น",
"too-many-requests": "ทำรายการบ่อยเกินไป กรุณารอสักครู่แล้วลองใหม่",
"comment-abusive-language": "ความคิดเห็นมีคำหยาบหรือคำดูหมิ่น กรุณาแก้ไขข้อความก่อนส่ง",
"comment-spam": "ความคิดเห็นมีลิงก์หรือข้อความซ้ำมากเกินไป กรุณาแก้ไขข้อความก่อนส่ง",
"comment-duplicate": "คุณส่งข้อความนี้แล้ว กรุณารอ 5 นาทีก่อนส่งข้อความเดิมอีกครั้ง",
"uploads-busy": "ระบบอัปโหลดกำลังใช้งานมาก กรุณาลองใหม่อีกครั้ง",
"image-too-large": "รูปภาพแต่ละรูปต้องมีขนาดไม่เกิน 10 MiB",
"body-too-large": "แนบรูปภาพได้สูงสุด 5 รูป รูปละไม่เกิน 10 MiB",
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { checkCommentContent, normalizeCommentText } from "./moderation";
describe("comment content moderation", () => {
it.each(["fuck you", "F U C K", "f.u.c.k", "f\u200buck", "fuck", "sh1t", "b!tch", "ไอ้เหี้ย", "เย็ดแม่", "ควย"])("rejects abusive language: %s", (text) => {
expect(() => checkCommentContent(text)).toThrow("comment-abusive-language");
});
it.each(["", "This weapon has classic passive stats", "Scunthorpe", "The boss has an assassin phase", "ทีมนี้ใช้กล้วยได้ไหม", "ขอบคุณสำหรับไกด์ครับ", "https://example.com/guide", "https://a.test https://b.test https://c.test", "ha ha ha", "Crit rate 100%!"])("allows ordinary discussion: %s", (text) => {
expect(() => checkCommentContent(text)).not.toThrow();
});
it.each(["https://a.test https://b.test https://c.test https://d.test", "www.a.test www.b.test www.c.test www.d.test", "a".repeat(20), "🔥".repeat(20), "buy now ".repeat(8), "spam ".repeat(8)])("rejects spam: %s", (text) => {
expect(() => checkCommentContent(text)).toThrow("comment-spam");
});
it("normalizes casing, whitespace, invisible characters and compatibility forms for duplicates", () => {
expect(normalizeCommentText(" Hello\u200b\n WORLD ")).toBe("hello world");
});
it("rejects text containing only invisible characters", () => {
expect(() => checkCommentContent("\u200b\u200d")).toThrow("empty-comment");
});
});
+26
View File
@@ -0,0 +1,26 @@
import { HttpError } from "@/lib/security/http";
// Keep the list focused: broad substring matches reject ordinary game discussion.
const englishTerms = ["fuck", "fucking", "fucker", "motherfucker", "shit", "bullshit", "bitch", "cunt", "asshole", "nigger", "nigga", "faggot"];
const substitutions: Record<string, string> = { a: "[a@4]", e: "[e3]", i: "[i1!]", o: "[o0]", s: "[s$5]", t: "[t7]" };
const abusiveEnglish = new RegExp(
`(?<![\\p{L}\\p{N}])(?:${englishTerms.map((term) => [...term].map((letter) => substitutions[letter] ?? letter).join("[\\s\\p{P}\\p{S}]*")).join("|")})(?![\\p{L}\\p{N}])`,
"u",
);
const abusiveThai = /(?:ไอ้เหี้ย|อีเหี้ย|ไอ้สัส|อีสัส|เย็ดแม่|ควย)/u;
export function normalizeCommentText(text: string) {
return text.normalize("NFKC").toLowerCase().replace(/\p{Cf}/gu, "").replace(/\s+/gu, " ").trim();
}
export function checkCommentContent(text: string) {
const normalized = normalizeCommentText(text);
if (text && !normalized) throw new HttpError(400, "empty-comment");
if (abusiveEnglish.test(normalized) || abusiveThai.test(normalized))
throw new HttpError(400, "comment-abusive-language");
if ((normalized.match(/(?:https?:\/\/|www\.)[^\s]+/gu)?.length ?? 0) > 3 ||
/(.)\1{19,}/u.test(normalized) ||
/(?:^|\s)(\S+(?:\s+\S+){0,4})(?:\s+\1){7,}(?=\s|$)/u.test(normalized))
throw new HttpError(400, "comment-spam");
return normalized;
}
+47 -44
View File
@@ -11,6 +11,7 @@ import { boundedBody, HttpError, withUploadSlot } from "@/lib/security/http";
import { authorizeComment, commentId, ensureCommentThread, getCommentTarget, withCommentLock } from "./repository";
import { MAX_COMMENT_BODY_BYTES, parseCommentForm } from "./validation";
import type { CommentViewer } from "./types";
import { withCommentSpamProtection } from "./spam";
type ParsedForm = ReturnType<typeof parseCommentForm>;
type Upload = { id: string; objectKey: string; mimeType: string; byteSize: number };
@@ -54,52 +55,54 @@ export async function publishComment(request: Request, viewer: CommentViewer, op
const destination = initial?.destination ?? await getCommentTarget(options.target!, viewer);
const result = await withUploadSlot(async () => {
const form = parseCommentForm(await boundedBody(request, MAX_COMMENT_BODY_BYTES).formData(), Boolean(options.id));
const uploaded: Upload[] = [];
try {
const storage = form.files.length ? await getMediaStorage() : null;
for (const file of form.files) {
const bytes = new Uint8Array(await file.arrayBuffer());
await inspectImage(bytes, file.type as "image/png" | "image/jpeg" | "image/webp");
const image = { id: crypto.randomUUID(), objectKey: `comments/${crypto.randomUUID()}`, mimeType: file.type, byteSize: file.size };
uploaded.push(image);
// Browser images load directly from the configured S3 public CDN.
await uploadCommentImage(storage!, image, bytes);
}
if (options.id) {
const version = await withCommentLock(options.id, viewer, async (tx, context) => {
const c = context.comment;
if (c.authorId !== viewer.id) throw new HttpError(403, "not-comment-author");
if (c.deletedAt || c.hidden || context.rootHidden) throw new HttpError(409, "comment-unavailable");
if (c.version !== form.version) throw new HttpError(409, "comment-edited-reload");
await saveRevision(tx, c.id, c.version + 1, form, uploaded);
await tx.update(comments).set({ version: c.version + 1 }).where(eq(comments.id, c.id));
return c.version + 1;
});
return { id: options.id, version };
}
const thread = await ensureCommentThread(options.target!, viewer);
return await getDb().transaction(async (tx) => {
await tx.select({ id: commentThreads.id }).from(commentThreads).where(eq(commentThreads.id, thread.id)).for("update");
const target = await getCommentTarget(options.target!, viewer, tx);
if (!target.writable) throw new HttpError(409, "guide-trashed");
let rootId: string | null = null;
if (form.replyToId) {
const parent = await authorizeComment(form.replyToId, viewer, tx);
if (parent.comment.threadId !== thread.id) throw new HttpError(400, "cross-thread-reply");
if (parent.comment.deletedAt || parent.comment.hidden || parent.rootHidden) throw new HttpError(409, "comment-unavailable");
rootId = parent.comment.rootId ?? parent.comment.id;
return withCommentSpamProtection(viewer.id, form.text, Boolean(options.id), async () => {
const uploaded: Upload[] = [];
try {
const storage = form.files.length ? await getMediaStorage() : null;
for (const file of form.files) {
const bytes = new Uint8Array(await file.arrayBuffer());
await inspectImage(bytes, file.type as "image/png" | "image/jpeg" | "image/webp");
const image = { id: crypto.randomUUID(), objectKey: `comments/${crypto.randomUUID()}`, mimeType: file.type, byteSize: file.size };
uploaded.push(image);
// Browser images load directly from the configured S3 public CDN.
await uploadCommentImage(storage!, image, bytes);
}
const [comment] = await tx.insert(comments).values({ threadId: thread.id, authorId: viewer.id, rootId, replyToId: form.replyToId }).returning({ id: comments.id });
await saveRevision(tx, comment.id, 1, form, uploaded);
return { id: comment.id, version: 1 };
});
} catch (cause) {
if (uploaded.length) {
const storage = await getMediaStorage();
for (const image of uploaded) await storage.delete(image.objectKey).catch(() => undefined);
if (options.id) {
const version = await withCommentLock(options.id, viewer, async (tx, context) => {
const c = context.comment;
if (c.authorId !== viewer.id) throw new HttpError(403, "not-comment-author");
if (c.deletedAt || c.hidden || context.rootHidden) throw new HttpError(409, "comment-unavailable");
if (c.version !== form.version) throw new HttpError(409, "comment-edited-reload");
await saveRevision(tx, c.id, c.version + 1, form, uploaded);
await tx.update(comments).set({ version: c.version + 1 }).where(eq(comments.id, c.id));
return c.version + 1;
});
return { id: options.id, version };
}
const thread = await ensureCommentThread(options.target!, viewer);
return await getDb().transaction(async (tx) => {
await tx.select({ id: commentThreads.id }).from(commentThreads).where(eq(commentThreads.id, thread.id)).for("update");
const target = await getCommentTarget(options.target!, viewer, tx);
if (!target.writable) throw new HttpError(409, "guide-trashed");
let rootId: string | null = null;
if (form.replyToId) {
const parent = await authorizeComment(form.replyToId, viewer, tx);
if (parent.comment.threadId !== thread.id) throw new HttpError(400, "cross-thread-reply");
if (parent.comment.deletedAt || parent.comment.hidden || parent.rootHidden) throw new HttpError(409, "comment-unavailable");
rootId = parent.comment.rootId ?? parent.comment.id;
}
const [comment] = await tx.insert(comments).values({ threadId: thread.id, authorId: viewer.id, rootId, replyToId: form.replyToId }).returning({ id: comments.id });
await saveRevision(tx, comment.id, 1, form, uploaded);
return { id: comment.id, version: 1 };
});
} catch (cause) {
if (uploaded.length) {
const storage = await getMediaStorage();
for (const image of uploaded) await storage.delete(image.objectKey).catch(() => undefined);
}
throw cause;
}
throw cause;
}
});
});
await notifyCommentChange(destination.target);
return result;
+68
View File
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("server-only", () => ({}));
const mocks = vi.hoisted(() => ({ limit: vi.fn(), set: vi.fn(), eval: vi.fn() }));
vi.mock("@/lib/security/rate-limit", () => ({ limitRequest: mocks.limit }));
vi.mock("@/lib/redis/client", () => ({ getRedisClient: async () => ({ set: mocks.set, eval: mocks.eval }) }));
import { withCommentSpamProtection } from "./spam";
import { HttpError } from "@/lib/security/http";
describe("comment spam protection", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.limit.mockResolvedValue(undefined);
mocks.set.mockResolvedValue("OK");
mocks.eval.mockResolvedValue(1);
});
it("limits all writes and retains the duplicate reservation on success", async () => {
const publish = vi.fn().mockResolvedValue({ id: "saved" });
await expect(withCommentSpamProtection("author", "Hello", false, publish)).resolves.toEqual({ id: "saved" });
expect(mocks.limit.mock.calls).toEqual([
["comment-write-hour", "author", 30, 3600],
["comment-write-minute", "author", 5],
["comment-write-cooldown", "author", 1, 5],
]);
expect(mocks.set).toHaveBeenCalledWith(expect.any(String), expect.any(String), "EX", 300, "NX");
expect(mocks.eval).not.toHaveBeenCalled();
});
it("uses the same private duplicate key for normalized text across targets", async () => {
await withCommentSpamProtection("author", " HELLO\u200b\n world", false, async () => undefined);
await withCommentSpamProtection("author", "hello world", false, async () => undefined);
expect(mocks.set.mock.calls[0][0]).toBe(mocks.set.mock.calls[1][0]);
expect(mocks.set.mock.calls[0][0]).not.toContain("hello");
await withCommentSpamProtection("other", "hello world", false, async () => undefined);
expect(mocks.set.mock.calls[2][0]).not.toBe(mocks.set.mock.calls[0][0]);
});
it("rejects simultaneous or recent duplicate posts before uploading or saving", async () => {
mocks.set.mockResolvedValue(null);
const publish = vi.fn();
await expect(withCommentSpamProtection("author", "Hello", false, publish)).rejects.toMatchObject({ status: 429, message: "comment-duplicate", retryAfter: 300 });
expect(publish).not.toHaveBeenCalled();
});
it("releases only its own reservation when publication fails", async () => {
const failure = new Error("upload failed");
await expect(withCommentSpamProtection("author", "Hello", false, async () => { throw failure; })).rejects.toBe(failure);
const [key, token] = mocks.set.mock.calls[0];
expect(mocks.eval).toHaveBeenCalledWith(expect.stringContaining("ARGV[1]"), 1, key, token);
});
it("blocks abuse in edits and leaves persistence untouched", async () => {
const publish = vi.fn();
await expect(withCommentSpamProtection("author", "fuck you", true, publish)).rejects.toMatchObject({ status: 400, message: "comment-abusive-language" });
expect(publish).not.toHaveBeenCalled();
expect(mocks.set).not.toHaveBeenCalled();
});
it.each([true, false])("keeps edits and image-only comments usable while rate limiting them (editing=%s)", async (editing) => {
const publish = vi.fn().mockResolvedValue("saved");
await expect(withCommentSpamProtection("author", editing ? "same text" : "", editing, publish)).resolves.toBe("saved");
expect(mocks.limit).toHaveBeenCalledTimes(3);
expect(mocks.set).not.toHaveBeenCalled();
});
it("stops publication if the shared rate limit or Redis is unavailable", async () => {
const publish = vi.fn();
mocks.limit.mockRejectedValueOnce(new HttpError(429, "too-many-requests", 5));
await expect(withCommentSpamProtection("author", "Hello", false, publish)).rejects.toMatchObject({ status: 429 });
mocks.set.mockRejectedValueOnce(new Error("Redis unavailable"));
await expect(withCommentSpamProtection("author", "Hello", false, publish)).rejects.toThrow("Redis unavailable");
expect(publish).not.toHaveBeenCalled();
});
});
+37
View File
@@ -0,0 +1,37 @@
import "server-only";
import { createHash, randomUUID } from "node:crypto";
import { getRedisClient } from "@/lib/redis/client";
import { HttpError } from "@/lib/security/http";
import { limitRequest } from "@/lib/security/rate-limit";
import { checkCommentContent } from "./moderation";
const duplicateWindow = 300;
const releaseScript = `
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
`;
export async function withCommentSpamProtection<T>(authorId: string, text: string, editing: boolean, publish: () => Promise<T>): Promise<T> {
const normalized = checkCommentContent(text);
await limitRequest("comment-write-hour", authorId, 30, 3600);
await limitRequest("comment-write-minute", authorId, 5);
await limitRequest("comment-write-cooldown", authorId, 1, 5);
// Edits may retain their text while changing images; image-only posts use the rate limits.
if (editing || !normalized) return publish();
const digest = createHash("sha256").update(JSON.stringify([authorId, normalized])).digest("hex");
const key = `${process.env.REDIS_SECURITY_PREFIX || "buzz:security"}:comment-duplicate:${digest}`;
const token = randomUUID();
const redis = await getRedisClient();
if (await redis.set(key, token, "EX", duplicateWindow, "NX") !== "OK")
throw new HttpError(429, "comment-duplicate", duplicateWindow);
try {
return await publish();
} catch (cause) {
// Failed uploads/saves can be retried; never remove a newer writer's reservation.
await redis.eval(releaseScript, 1, key, token).catch(() => undefined);
throw cause;
}
}
+24
View File
@@ -14,6 +14,7 @@ vi.mock("@/lib/comments/events", () => ({ notifyCommentChange: notify }));
const storage = vi.hoisted(() => ({ write: vi.fn(), delete: vi.fn() }));
const rateLimit = vi.hoisted(() => vi.fn());
vi.mock("@/lib/security/rate-limit", () => ({ limitRequest: rateLimit }));
vi.mock("@/lib/redis/client", () => ({ getRedisClient: async () => ({ set: async () => "OK", eval: async () => 1 }) }));
const session = vi.hoisted(() => ({ get: vi.fn() }));
vi.mock("@/lib/media/storage", () => ({ getMediaStorage: async () => storage, publicMediaUrl: (key: string) => `https://storage.example.test/${key}` }));
vi.mock("@/lib/auth/server", () => ({ getCustomerSession: session.get }));
@@ -380,6 +381,29 @@ describeDatabase("guide comments against PostgreSQL", { timeout: 30000 }, () =>
expect(read.status).toBe(200); expect(read.headers.get("cache-control")).toContain("no-store");
expect((await read.json()).items).toHaveLength(1);
});
it("rejects abusive posts, replies and edits before uploads, revisions or notifications", async () => {
session.get.mockResolvedValue({ user: { id: author.id }, session: { id: "session" } });
const url = `https://guide.example.test/api/comments?target=${target}`;
const blocked = await commentsRoute.POST(request("f.u.c.k", { images: [png] }, url));
expect(blocked.status).toBe(400);
expect(await blocked.json()).toEqual({ error: "comment-abusive-language" });
expect((await page()).items).toHaveLength(0);
expect(storage.write).not.toHaveBeenCalled();
expect(notify).not.toHaveBeenCalled();
const root = await post("Normal discussion");
notify.mockClear();
vi.mocked(after).mockClear();
const reply = await commentsRoute.POST(request("ไอ้เหี้ย", { reply: root.id }, url));
expect(reply.status).toBe(400);
const edited = await itemRoute.PATCH(request("buy now ".repeat(8), { version: 1, images: [png] }), { params: Promise.resolve({ id: root.id }) });
expect(edited.status).toBe(400);
expect(await edited.json()).toEqual({ error: "comment-spam" });
expect((await page()).items).toHaveLength(1);
expect((await commentHistory(root.id, author)).items).toHaveLength(1);
expect(storage.write).not.toHaveBeenCalled();
expect(notify).not.toHaveBeenCalled();
expect(after).not.toHaveBeenCalled();
});
it("enforces author and admin rights through mutation endpoints", async () => {
const root = await post();
session.get.mockResolvedValue({ user: { id: other.id }, session: { id: "session" } });