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 recorded in the activity log. Comment reads and legacy attachment redirects
are not cached; guide content caches are independent of discussions. 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 The comment migration must run before deploying these pages. For PostgreSQL
integration verification, point `DATABASE_INTEGRATION_URL` at a migrated test or integration verification, point `DATABASE_INTEGRATION_URL` at a migrated test or
development database and run `bun run test tests/comments.integration.test.ts`. 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> = { export const COMMENT_ERRORS: Record<string, string> = {
"sign-in-required": "กรุณาเข้าสู่ระบบเพื่อแสดงความคิดเห็น", "sign-in-required": "กรุณาเข้าสู่ระบบเพื่อแสดงความคิดเห็น",
"too-many-requests": "ทำรายการบ่อยเกินไป กรุณารอสักครู่แล้วลองใหม่", "too-many-requests": "ทำรายการบ่อยเกินไป กรุณารอสักครู่แล้วลองใหม่",
"comment-abusive-language": "ความคิดเห็นมีคำหยาบหรือคำดูหมิ่น กรุณาแก้ไขข้อความก่อนส่ง",
"comment-spam": "ความคิดเห็นมีลิงก์หรือข้อความซ้ำมากเกินไป กรุณาแก้ไขข้อความก่อนส่ง",
"comment-duplicate": "คุณส่งข้อความนี้แล้ว กรุณารอ 5 นาทีก่อนส่งข้อความเดิมอีกครั้ง",
"uploads-busy": "ระบบอัปโหลดกำลังใช้งานมาก กรุณาลองใหม่อีกครั้ง", "uploads-busy": "ระบบอัปโหลดกำลังใช้งานมาก กรุณาลองใหม่อีกครั้ง",
"image-too-large": "รูปภาพแต่ละรูปต้องมีขนาดไม่เกิน 10 MiB", "image-too-large": "รูปภาพแต่ละรูปต้องมีขนาดไม่เกิน 10 MiB",
"body-too-large": "แนบรูปภาพได้สูงสุด 5 รูป รูปละไม่เกิน 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;
}
+3
View File
@@ -11,6 +11,7 @@ import { boundedBody, HttpError, withUploadSlot } from "@/lib/security/http";
import { authorizeComment, commentId, ensureCommentThread, getCommentTarget, withCommentLock } from "./repository"; import { authorizeComment, commentId, ensureCommentThread, getCommentTarget, withCommentLock } from "./repository";
import { MAX_COMMENT_BODY_BYTES, parseCommentForm } from "./validation"; import { MAX_COMMENT_BODY_BYTES, parseCommentForm } from "./validation";
import type { CommentViewer } from "./types"; import type { CommentViewer } from "./types";
import { withCommentSpamProtection } from "./spam";
type ParsedForm = ReturnType<typeof parseCommentForm>; type ParsedForm = ReturnType<typeof parseCommentForm>;
type Upload = { id: string; objectKey: string; mimeType: string; byteSize: number }; type Upload = { id: string; objectKey: string; mimeType: string; byteSize: number };
@@ -54,6 +55,7 @@ export async function publishComment(request: Request, viewer: CommentViewer, op
const destination = initial?.destination ?? await getCommentTarget(options.target!, viewer); const destination = initial?.destination ?? await getCommentTarget(options.target!, viewer);
const result = await withUploadSlot(async () => { const result = await withUploadSlot(async () => {
const form = parseCommentForm(await boundedBody(request, MAX_COMMENT_BODY_BYTES).formData(), Boolean(options.id)); const form = parseCommentForm(await boundedBody(request, MAX_COMMENT_BODY_BYTES).formData(), Boolean(options.id));
return withCommentSpamProtection(viewer.id, form.text, Boolean(options.id), async () => {
const uploaded: Upload[] = []; const uploaded: Upload[] = [];
try { try {
const storage = form.files.length ? await getMediaStorage() : null; const storage = form.files.length ? await getMediaStorage() : null;
@@ -101,6 +103,7 @@ export async function publishComment(request: Request, viewer: CommentViewer, op
throw cause; throw cause;
} }
}); });
});
await notifyCommentChange(destination.target); await notifyCommentChange(destination.target);
return result; 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 storage = vi.hoisted(() => ({ write: vi.fn(), delete: vi.fn() }));
const rateLimit = vi.hoisted(() => vi.fn()); const rateLimit = vi.hoisted(() => vi.fn());
vi.mock("@/lib/security/rate-limit", () => ({ limitRequest: rateLimit })); 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() })); 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/media/storage", () => ({ getMediaStorage: async () => storage, publicMediaUrl: (key: string) => `https://storage.example.test/${key}` }));
vi.mock("@/lib/auth/server", () => ({ getCustomerSession: session.get })); 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(read.status).toBe(200); expect(read.headers.get("cache-control")).toContain("no-store");
expect((await read.json()).items).toHaveLength(1); 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 () => { it("enforces author and admin rights through mutation endpoints", async () => {
const root = await post(); const root = await post();
session.get.mockResolvedValue({ user: { id: other.id }, session: { id: "session" } }); session.get.mockResolvedValue({ user: { id: other.id }, session: { id: "session" } });