Form Update

This commit is contained in:
Greg Kilmer
2026-07-07 15:32:56 -04:00
parent cec1078d0e
commit 2e31aac9bf
9 changed files with 440 additions and 26 deletions
+44 -3
View File
@@ -5,9 +5,10 @@
* 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. Deliver: SMTP via nodemailer if configured, else POST to a webhook if
* 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.
* 5. Always return JSON { ok } or { error }.
* 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.
@@ -22,6 +23,9 @@ const schema = z.object({
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) ------------------------------------
@@ -57,6 +61,36 @@ function clientIp(request: Request): string {
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 = process.env.TURNSTILE_SECRET_KEY;
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') {
@@ -74,7 +108,7 @@ export const POST: APIRoute = async ({ request }) => {
if (!parsed.success) {
return json({ error: 'Please complete all fields correctly.', issues: parsed.error.issues }, 400);
}
const { name, email, message } = parsed.data;
const { name, email, message, turnstileToken } = parsed.data;
// Rate limit.
const ip = clientIp(request);
@@ -82,6 +116,13 @@ export const POST: APIRoute = async ({ request }) => {
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 });