SPB Git

Repository Public

route.ts 3.1 KB 66 lines typescript

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
Files main
Menu

Theme