38 lines
1.6 KiB
TypeScript
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;
|
|
}
|
|
}
|