Repository Public
1import nodemailer from "nodemailer";2import { site } from "@/lib/data";3import { getDict } from "@/lib/i18n";4import { requestBody } from "@/lib/mailto";5import { accessRequestSchema } from "@/lib/schemas";67export const dynamic = "force-dynamic";89const WINDOW_MS = 60 * 60 * 1000;10const MAX_PER_WINDOW = 5;11const hits = new Map<string, number[]>();1213function limited(key: string, now = Date.now()): boolean {14 const recent = (hits.get(key) ?? []).filter((t) => now - t < WINDOW_MS);15 if (recent.length >= MAX_PER_WINDOW) return true;16 recent.push(now);17 hits.set(key, recent);18 if (hits.size > 5000) for (const [k, v] of hits) if (v.every((t) => now - t >= WINDOW_MS)) hits.delete(k);19 return false;20}2122const json = (body: unknown, status: number) => Response.json(body, { status, headers: { "Cache-Control": "no-store" } });2324/**25 * Server-side delivery of an access request, only when an SMTP provider is configured.26 * Without one the endpoint answers 501 and the page uses its mailto: flow instead.27 * Nothing is persisted; request contents are never logged.28 */29export async function POST(request: Request) {30 const { SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, ACCESS_MAIL_TO, ACCESS_MAIL_FROM } = process.env;31 if (!SMTP_HOST || !ACCESS_MAIL_TO) return json({ error: "mail_not_configured" }, 501);3233 // CSRF: same-origin JSON only (a cross-site form cannot send application/json without a preflight).34 const origin = request.headers.get("origin");35 const allowed = new Set([site.url, process.env.NEXT_PUBLIC_SITE_URL].filter(Boolean));36 if (!origin || !allowed.has(origin)) return json({ error: "forbidden" }, 403);37 if (!(request.headers.get("content-type") ?? "").startsWith("application/json")) return json({ error: "unsupported_media_type" }, 415);3839 const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";40 if (limited(ip)) return json({ error: "rate_limited" }, 429);4142 let payload: unknown;43 try {44 const text = await request.text();45 if (text.length > 20_000) return json({ error: "too_large" }, 413);46 payload = JSON.parse(text);47 } catch {48 return json({ error: "invalid_json" }, 400);49 }50 const parsed = accessRequestSchema.safeParse(payload);51 if (!parsed.success) return json({ error: "invalid", fields: parsed.error.issues.map((i) => i.path.join(".")) }, 400);52 const data = parsed.data;53 if (data.website) return json({ ok: true }, 200); // honeypot: drop silently5455 const f = getDict(data.locale).access.form;56 const strip = (s: string) => s.replace(/[\r\n]+/g, " ").slice(0, 200);57 try {58 const transport = nodemailer.createTransport({ host: SMTP_HOST, port: Number(SMTP_PORT ?? 587), secure: Number(SMTP_PORT) === 465, auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined });59 await transport.sendMail({ from: ACCESS_MAIL_FROM ?? ACCESS_MAIL_TO, to: ACCESS_MAIL_TO, replyTo: strip(data.email), subject: strip(f.subject(data.projectTitle)), text: requestBody(data, f) });60 return json({ ok: true }, 200);61 } catch (err) {62 console.error("access request: mail delivery failed", err instanceof Error ? err.name : "error");63 return json({ error: "delivery_failed" }, 502);64 }65}66