import { createHash } from "crypto"; import { headers } from "next/headers"; import { prisma } from "@/lib/prisma"; import { CONTACT_RATE_LIMIT_KEY_PREFIX, type ContactProtectionSettings, } from "@/lib/contact-protection"; async function getClientIpFromHeaders() { const requestHeaders = await headers(); const forwardedFor = requestHeaders.get("x-forwarded-for"); if (forwardedFor) { return forwardedFor.split(",")[0]?.trim() || "unknown"; } return requestHeaders.get("x-real-ip")?.trim() || "unknown"; } function getRateLimitKey(ip: string, windowMinutes: number) { const windowMs = windowMinutes * 60 * 1000; const windowStart = Math.floor(Date.now() / windowMs) * windowMs; const ipHash = createHash("sha256").update(ip).digest("hex"); return `${CONTACT_RATE_LIMIT_KEY_PREFIX}:${ipHash}:${windowStart}`; } function parseCount(rawValue: string | null | undefined) { if (!rawValue) { return 0; } const parsed = Number.parseInt(rawValue, 10); return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0; } export async function enforceContactRateLimit(settings: ContactProtectionSettings) { if (!settings.rateLimit.enabled) { return; } const ip = await getClientIpFromHeaders(); const key = getRateLimitKey(ip, settings.rateLimit.windowMinutes); // Clean up stale rate limit entries (older than 2x the window) to prevent table bloat. const cutoffDate = new Date(Date.now() - settings.rateLimit.windowMinutes * 2 * 60 * 1000); await prisma.$executeRaw` DELETE FROM "AppConfig" WHERE key LIKE ${`${CONTACT_RATE_LIMIT_KEY_PREFIX}:%`} AND "updatedAt" < ${cutoffDate} `; // Atomically insert or increment the counter for this IP + window. const result = await prisma.$queryRaw>` INSERT INTO "AppConfig" (id, key, value, "createdAt", "updatedAt") VALUES (gen_random_uuid()::text, ${key}, '1', NOW(), NOW()) ON CONFLICT (key) DO UPDATE SET value = (CAST("AppConfig".value AS INTEGER) + 1)::text, "updatedAt" = NOW() RETURNING CAST(value AS INTEGER) AS count `; const count = result[0]?.count ?? 0; if (count > settings.rateLimit.maxRequests) { throw new Error("Too many contact requests. Please try again later."); } } export async function verifyTurnstileToken( settings: ContactProtectionSettings, token: string, ) { if (!settings.turnstile.enabled) { return; } if (!settings.turnstile.siteKey || !settings.turnstile.secretKey) { throw new Error("Turnstile is enabled but not fully configured."); } if (!token.trim()) { throw new Error("Turnstile verification is required."); } const body = new URLSearchParams(); body.set("secret", settings.turnstile.secretKey); body.set("response", token); body.set("remoteip", await getClientIpFromHeaders()); const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body, cache: "no-store", }); if (!response.ok) { throw new Error("Turnstile verification request failed."); } const result = await response.json() as { success?: boolean; }; if (!result.success) { throw new Error("Turnstile verification failed."); } }