Files
buzz-sheet/lib/comments/spam.ts
T
gunshiz 20c52fac04
CI / Verify (push) Successful in 1m41s
CI / Build immutable images and deploy (push) Successful in 2m30s
feat(comments) : filter abusive language and prevent spam
2026-10-08 04:45:45 +07:00

38 lines
1.6 KiB
TypeScript

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