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
+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;