From 20c52fac04e4b251bf64b29893b29e48c43d8ae1 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Thu, 8 Oct 2026 04:45:45 +0700 Subject: [PATCH] feat(comments) : filter abusive language and prevent spam --- README.md | 10 ++++ components/comments/client.ts | 3 + lib/comments/moderation.test.ts | 20 +++++++ lib/comments/moderation.ts | 26 +++++++++ lib/comments/publish.ts | 91 +++++++++++++++--------------- lib/comments/spam.test.ts | 68 ++++++++++++++++++++++ lib/comments/spam.ts | 37 ++++++++++++ tests/comments.integration.test.ts | 24 ++++++++ 8 files changed, 235 insertions(+), 44 deletions(-) create mode 100644 lib/comments/moderation.test.ts create mode 100644 lib/comments/moderation.ts create mode 100644 lib/comments/spam.test.ts create mode 100644 lib/comments/spam.ts diff --git a/README.md b/README.md index 0fdd631..eaf7f65 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/components/comments/client.ts b/components/comments/client.ts index 74b1dc7..cdf7b60 100644 --- a/components/comments/client.ts +++ b/components/comments/client.ts @@ -1,6 +1,9 @@ export const COMMENT_ERRORS: Record = { "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", diff --git a/lib/comments/moderation.test.ts b/lib/comments/moderation.test.ts new file mode 100644 index 0000000..96699dd --- /dev/null +++ b/lib/comments/moderation.test.ts @@ -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"); + }); +}); diff --git a/lib/comments/moderation.ts b/lib/comments/moderation.ts new file mode 100644 index 0000000..a1084a6 --- /dev/null +++ b/lib/comments/moderation.ts @@ -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 = { a: "[a@4]", e: "[e3]", i: "[i1!]", o: "[o0]", s: "[s$5]", t: "[t7]" }; +const abusiveEnglish = new RegExp( + `(? [...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; +} diff --git a/lib/comments/publish.ts b/lib/comments/publish.ts index de04602..66a7411 100644 --- a/lib/comments/publish.ts +++ b/lib/comments/publish.ts @@ -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; 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; diff --git a/lib/comments/spam.test.ts b/lib/comments/spam.test.ts new file mode 100644 index 0000000..2c67107 --- /dev/null +++ b/lib/comments/spam.test.ts @@ -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(); + }); +}); diff --git a/lib/comments/spam.ts b/lib/comments/spam.ts new file mode 100644 index 0000000..86195bd --- /dev/null +++ b/lib/comments/spam.ts @@ -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(authorId: string, text: string, editing: boolean, publish: () => Promise): Promise { + 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; + } +} diff --git a/tests/comments.integration.test.ts b/tests/comments.integration.test.ts index 3bdab85..5f046fb 100644 --- a/tests/comments.integration.test.ts +++ b/tests/comments.integration.test.ts @@ -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" } });