Files
com.gregkilmer.www/src/pages/api/contact.ts
T
2026-07-07 22:10:17 -04:00

219 lines
7.8 KiB
TypeScript

/**
* /api/contact
* ---------------------------------------------------------------------------
* POST endpoint that receives the contact form. Pipeline:
* 1. Reject non-POST.
* 2. Validate JSON body with Zod (name, email, message; honeypot must be empty).
* 3. Rate-limit per IP (in-memory windowed counter).
* 4. Verify Cloudflare Turnstile token (if TURNSTILE_SECRET_KEY is set).
* 5. Deliver: SMTP via nodemailer if configured, else POST to a webhook if
* configured, else return 503 so delivery can be wired later.
* 6. Always return JSON { ok } or { error }.
*
* Secrets come from env (see .env.example). Nothing secret ships to the client.
* This runs server-side only via the @astrojs/node adapter.
*/
import type { APIRoute } from 'astro';
import { z } from 'zod/v4';
import {
contactTo,
contactWebhookUrl,
env,
smtpFrom,
smtpHost,
smtpPass,
smtpPort,
smtpUser,
turnstileSecretKey,
} from '@lib/env';
// --- Validation schema ------------------------------------------------------
const schema = z.object({
name: z.string().min(1).max(120),
email: z.string().email().max(200),
message: z.string().min(10).max(5000),
// Honeypot: must be absent/empty. Bots tend to fill every field.
company: z.string().max(0).optional().or(z.literal('')),
// Turnstile token. Required when TURNSTILE_SECRET_KEY is set; ignored
// otherwise (dev). Validated against Cloudflare's siteverify endpoint below.
turnstileToken: z.string().optional(),
});
// --- In-memory rate limiter (per worker) ------------------------------------
// Good enough for a personal site behind a reverse proxy. For multi-instance,
// swap this for Redis/Upstash. Window = N submissions per IP per X minutes.
// Tunables are read per-request via the env helper so they resolve in both dev
// (import.meta.env) and Docker (process.env).
const hits = new Map<string, { count: number; reset: number }>();
function rateLimited(ip: string): boolean {
const rateWindowMin = Number(env('CONTACT_RATE_WINDOW_MIN') ?? 15);
const rateLimit = Number(env('CONTACT_RATE_LIMIT') ?? 5);
const now = Date.now();
const entry = hits.get(ip);
if (!entry || now > entry.reset) {
hits.set(ip, { count: 1, reset: now + rateWindowMin * 60_000 });
return false;
}
entry.count += 1;
return entry.count > rateLimit;
}
// --- Helpers ----------------------------------------------------------------
function json(body: unknown, status: number) {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
function clientIp(request: Request): string {
// Cosmos/Traefik typically forwards the real client IP here.
const fwd = request.headers.get('x-forwarded-for');
if (fwd) return fwd.split(',')[0]!.trim();
return request.headers.get('x-real-ip') ?? 'unknown';
}
// --- Turnstile verification -------------------------------------------------
// Skipped entirely when TURNSTILE_SECRET_KEY is unset (local dev). When set,
// the token from the client is POSTed to Cloudflare's siteverify endpoint
// along with the visitor's IP for fraud scoring. Only valid tokens proceed.
async function verifyTurnstile(token: string | undefined, ip: string): Promise<boolean> {
const secret = turnstileSecretKey();
if (!secret) return true; // Turnstile not configured -> skip.
if (!token) return false;
try {
const form = new URLSearchParams();
form.set('secret', secret);
form.set('response', token);
if (ip && ip !== 'unknown') form.set('remoteip', ip);
const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
body: form,
});
const data = (await res.json()) as { success: boolean };
return Boolean(data.success);
} catch (err) {
// Network error talking to Cloudflare. Fail closed: reject the submission
// rather than letting a bot through on a transient outage.
console.error('[contact] turnstile verification failed:', err);
return false;
}
}
// --- Route handler ----------------------------------------------------------
export const POST: APIRoute = async ({ request }) => {
if (request.method !== 'POST') {
return json({ error: 'Method not allowed.' }, 405);
}
// Parse + validate.
let payload: unknown;
try {
payload = await request.json();
} catch {
return json({ error: 'Invalid JSON body.' }, 400);
}
const parsed = schema.safeParse(payload);
if (!parsed.success) {
return json({ error: 'Please complete all fields correctly.', issues: parsed.error.issues }, 400);
}
const { name, email, message, turnstileToken } = parsed.data;
// Rate limit.
const ip = clientIp(request);
if (rateLimited(ip)) {
return json({ error: 'Too many submissions. Please try again later.' }, 429);
}
// Turnstile. Skipped when no secret is configured (dev). Fails closed on
// network errors so a Cloudflare outage can't open the door to bots.
const turnstileOk = await verifyTurnstile(turnstileToken, ip);
if (!turnstileOk) {
return json({ error: 'Bot check failed. Please refresh and try again.' }, 400);
}
// Deliver.
try {
const delivered = await deliver({ name, email, message });
if (!delivered) {
// No SMTP or webhook configured. Tell the client clearly without leaking
// server internals, but return 503 so it is visibly a config gap.
return json({ error: 'Contact delivery is not configured yet. Please email me directly.' }, 503);
}
return json({ ok: true }, 200);
} catch (err) {
console.error('[contact] delivery failed:', err);
return json({ error: 'Could not send your message right now. Please try again later.' }, 502);
}
};
// --- Delivery: SMTP first, webhook fallback ---------------------------------
interface Message {
name: string;
email: string;
message: string;
}
async function deliver(msg: Message): Promise<boolean> {
const host = smtpHost();
const webhook = contactWebhookUrl();
if (host) {
// Dynamic import keeps nodemailer out of cold-start when unused.
const nodemailer = (await import('nodemailer')).default;
const port = Number(smtpPort() ?? 587);
const user = smtpUser();
// Accept self-signed certificates when explicitly opted in. Intended for
// internal homelab hops where the SMTP relay (e.g. Stalwart on the same
// Docker network) presents a self-signed cert and the connection never
// traverses an untrusted network. Defaults to false so production-grade
// TLS verification is the default; the override is a deliberate choice.
const allowSelfSigned = env('SMTP_ALLOW_SELFSIGNED') === 'true';
const transport = nodemailer.createTransport({
host,
port,
secure: port === 465,
auth: user ? { user, pass: smtpPass() } : undefined,
...(allowSelfSigned
? { tls: { rejectUnauthorized: false } }
: {}),
});
const hostname = (() => {
try {
return new URL(process.env.SITE_URL ?? 'https://gregkilmer.com').hostname;
} catch {
return 'gregkilmer.com';
}
})();
await transport.sendMail({
from: smtpFrom() ?? `Greg's Site <no-reply@${hostname}>`,
to: contactTo() ?? 'greg@gregkilmer.com',
replyTo: `${msg.name} <${msg.email}>`,
subject: `New message from ${msg.name} via gregkilmer.com`,
text: `Name: ${msg.name}\nEmail: ${msg.email}\n\n${msg.message}`,
});
return true;
}
if (webhook) {
const res = await fetch(webhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `New contact from ${msg.name} (${msg.email}):\n\n${msg.message}`,
// Structured fields for webhook consumers (Discord/Slack/n8n).
name: msg.name,
email: msg.email,
message: msg.message,
}),
});
return res.ok;
}
return false;
}