feat(comments) : filter abusive language and prevent spam
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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" } });
|
||||
|
||||
Reference in New Issue
Block a user