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