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 });
+41 -7
View File
@@ -16,14 +16,13 @@ import Icon from "@components/Icon.astro";
import SectionHeader from "@components/layout/SectionHeader.astro";
import Placeholder from "@components/layout/Placeholder.astro";
import ContactForm from "@components/professional/ContactForm.tsx";
import ObfuscatedEmail from "@components/ObfuscatedEmail.astro";
// Email is rendered separately via <ObfuscatedEmail> so the plaintext address
// never appears in the HTML source. The other entries are plain links.
const emailAddress = "contact@gregkilmer.com";
const professional = [
{
icon: "email-outline",
label: "Email",
href: "",
hint: "Kilmer.GM@Gmail.com",
},
{
icon: "linkedin",
label: "LinkedIn",
@@ -56,11 +55,19 @@ const social = [
hint: "Server link, if public.",
},
];
// Turnstile site key. Public per Cloudflare (safe to ship to the client), but
// read here server-side and passed as a prop so it never needs the PUBLIC_
// prefix and stays consistent with TURNSTILE_SECRET_KEY. Undefined when
// Turnstile is not configured, which makes ContactForm skip the widget entirely.
const turnstileSiteKey = import.meta.env.TURNSTILE_SITE_KEY as
string | undefined;
---
<BaseLayout
title="Contact"
description="Reach Greg Kilmer for cloud development, SAP BTP, technical leadership, or just to debate the best Gundam series or find a fourth for game night."
noindex={true}
>
<section class="container-prose flex flex-col gap-10 pt-28 sm:pt-32">
<Reveal>
@@ -86,6 +93,33 @@ const social = [
Professional
</h2>
<ul class="flex flex-col gap-3">
{
/* Email: rendered via ObfuscatedEmail so the address is not in
the HTML source. JS decodes it into a mailto: on click. */
}
<li class="card flex items-center gap-3">
<span
class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
style="background: color-mix(in srgb, var(--color-accent) 16%, transparent); color: var(--color-accent);"
aria-hidden="true"
>
<Icon name="email-outline" class="h-5 w-5" />
</span>
<span class="flex flex-col gap-0.5">
<span
class="font-sans text-sm font-semibold"
style="color: var(--color-text);"
>
Email
</span>
<span class="text-xs" style="color: var(--color-text-subtle);">
<ObfuscatedEmail
address={emailAddress}
class="font-mono uppercase tracking-[0.1em]"
/>
</span>
</span>
</li>
{
professional.map((item) => (
<li class="card flex items-center gap-3">
@@ -193,7 +227,7 @@ const social = [
Prefer a form? Drop a note and it will land in my inbox.
</p>
</div>
<ContactForm client:idle />
<ContactForm client:idle turnstileSiteKey={turnstileSiteKey} />
</section>
</Reveal>
</div>
+4 -4
View File
@@ -24,6 +24,7 @@ import AlchemicalDivider from "@components/mystic/AlchemicalDivider.astro";
import Astrolabe from "@components/mystic/Astrolabe.astro";
import CircuitField from "@components/mystic/CircuitField.tsx";
import CelestialFrame from "@components/mystic/CelestialFrame.astro";
import ObfuscatedEmail from "@components/ObfuscatedEmail.astro";
import { getCollection } from "astro:content";
const experience = (await getCollection("experience")).sort(
@@ -270,12 +271,11 @@ const projects = (await getCollection("projects")).sort(
class="h-5 w-5"
style="color: var(--color-accent);"
/>
<span
<ObfuscatedEmail
address="contact@gregkilmer.com"
class="font-mono text-xs uppercase tracking-[0.18em]"
style="color: var(--color-accent);"
>
Kilmer.GM@Gmail.com
</span>
/>
</li>
<li class="flex items-center gap-2">
<Icon