113 lines
2.8 KiB
TypeScript
113 lines
2.8 KiB
TypeScript
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";
|
|
|
|
function getClientIpFromHeaders() {
|
|
const requestHeaders = 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 = getClientIpFromHeaders();
|
|
const key = getRateLimitKey(ip, settings.rateLimit.windowMinutes);
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
const current = await tx.appConfig.findUnique({
|
|
where: { key },
|
|
select: { value: true },
|
|
});
|
|
const nextCount = parseCount(current?.value) + 1;
|
|
|
|
if (nextCount > settings.rateLimit.maxRequests) {
|
|
throw new Error("Too many contact requests. Please try again later.");
|
|
}
|
|
|
|
await tx.appConfig.upsert({
|
|
where: { key },
|
|
update: {
|
|
value: String(nextCount),
|
|
},
|
|
create: {
|
|
key,
|
|
value: "1",
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
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", 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.");
|
|
}
|
|
}
|