Add SMTP admin settings and contact form delivery
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
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.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user