Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@ CONTACT_TO_EMAIL=you@example.com

# Contact form sender (optional - defaults to onboarding@resend.dev)
# CONTACT_FROM_EMAIL=contact@yourdomain.com

# Cloudflare Turnstile (https://dash.cloudflare.com -> Turnstile)
# Leave both unset to disable the widget entirely; the form still works.
TURNSTILE_SECRET_KEY=0x4AAAAAAAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
2 changes: 2 additions & 0 deletions src/app.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ declare global {
// Workers rate limiting binding. Absent locally and in tests, where the
// in-memory limiter in lib/server/contact.ts is used instead.
CONTACT_RATE_LIMIT?: { limit(options: { key: string }): Promise<{ success: boolean }> };
// Public Turnstile site key, set as a var in wrangler.jsonc.
PUBLIC_TURNSTILE_SITE_KEY?: string;
};
}
}
Expand Down
60 changes: 60 additions & 0 deletions src/lib/server/contact.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
checkRateLimit,
verifyTurnstile,
consumeRateLimit,
sendContactEmail,
validateContactSubmission,
Expand Down Expand Up @@ -104,3 +105,62 @@ describe('rate limiting on Workers', () => {
expect((await checkRateLimit(client, limiter, 1_000)).allowed).toBe(false);
});
});

describe('turnstile verification', () => {
const ok = async () => new Response(JSON.stringify({ success: true }), { status: 200 });

it('accepts a token the siteverify endpoint approves', async () => {
let sent: URLSearchParams | undefined;
const fetcher = async (_u: string | URL | Request, init?: RequestInit) => {
sent = new URLSearchParams(String(init?.body));
return ok();
};
const r = await verifyTurnstile('tok-123', 'sec', '1.2.3.4', fetcher as typeof fetch);
expect(r.ok).toBe(true);
expect(sent?.get('response')).toBe('tok-123');
expect(sent?.get('secret')).toBe('sec');
expect(sent?.get('remoteip')).toBe('1.2.3.4');
});

it('rejects a token the endpoint refuses, and surfaces the reason', async () => {
const fetcher = async () =>
new Response(JSON.stringify({ success: false, 'error-codes': ['invalid-input-response'] }), {
status: 200,
});
const r = await verifyTurnstile('bad', 'sec', undefined, fetcher as typeof fetch);
expect(r.ok).toBe(false);
expect(r.reason).toContain('invalid-input-response');
});

it('rejects an empty token without calling the network', async () => {
let called = false;
const fetcher = async () => {
called = true;
return ok();
};
const r = await verifyTurnstile('', 'sec', undefined, fetcher as typeof fetch);
expect(r.ok).toBe(false);
expect(called).toBe(false);
});

it('skips verification when no secret is configured', async () => {
let called = false;
const fetcher = async () => {
called = true;
return ok();
};
const r = await verifyTurnstile('', '', undefined, fetcher as typeof fetch);
expect(r.ok).toBe(true);
expect(called).toBe(false);
});

it('lets a submission through if siteverify itself is unreachable', async () => {
const fetcher = async () => {
throw new Error('network down');
};
// A Cloudflare outage should not silently kill the contact form; the rate
// limiter is still in front of this.
const r = await verifyTurnstile('tok', 'sec', undefined, fetcher as typeof fetch);
expect(r.ok).toBe(true);
});
});
33 changes: 33 additions & 0 deletions src/lib/server/contact.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,40 @@
const RESEND_API_URL = 'https://api.resend.com/emails';
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
const WINDOW_MS = 10 * 60 * 1000;
const MAX_REQUESTS = 5;

/**
* Check a Turnstile token with Cloudflare.
*
* Two deliberate escape hatches. With no secret configured this returns ok, so
* local development and tests are not blocked by a widget that isn't set up.
* And if siteverify itself cannot be reached we let the submission through
* rather than let a Cloudflare outage silently kill the contact form; the rate
* limiter still sits in front of this.
*/
export async function verifyTurnstile(
token: string,
secret: string,
remoteip: string | undefined,
fetcher: typeof fetch,
): Promise<{ ok: boolean; reason?: string }> {
if (!secret) return { ok: true };
if (!token) return { ok: false, reason: 'missing turnstile token' };

const body = new URLSearchParams({ secret, response: token });
if (remoteip) body.set('remoteip', remoteip);

try {
const res = await fetcher(TURNSTILE_VERIFY_URL, { method: 'POST', body });
const data = (await res.json()) as { success?: boolean; 'error-codes'?: string[] };
if (data.success) return { ok: true };
return { ok: false, reason: (data['error-codes'] ?? ['verification failed']).join(', ') };
} catch (error) {
console.error('Turnstile siteverify unreachable, allowing submission', error);
return { ok: true };
}
}

export interface ContactSubmission {
name: string;
email: string;
Expand Down
30 changes: 29 additions & 1 deletion src/routes/contact/+page.server.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,26 @@
import { env } from '$env/dynamic/private';
import { fail } from '@sveltejs/kit';
import type { Actions } from './$types';
import { checkRateLimit, sendContactEmail, validateContactSubmission } from '$lib/server/contact';
import {
checkRateLimit,
sendContactEmail,
validateContactSubmission,
verifyTurnstile,
} from '$lib/server/contact';

// Form actions need a live handler, so this route opts out of the site-wide
// prerender set in +layout.ts.
export const prerender = false;

// The site key is public and only needed so the widget can render. Read at
// runtime from the Worker binding rather than $env/static/public, because a
// static import is baked in at build time and the CI builder has no .env.local.
// Absent (plain vite dev) the widget simply does not render, and verifyTurnstile
// skips too, so the form still works locally.
export const load = ({ platform }) => ({
turnstileSiteKey: platform?.env?.PUBLIC_TURNSTILE_SITE_KEY ?? '',
});

export const actions = {
default: async ({ request, getClientAddress, fetch, platform }) => {
const form = await request.formData();
Expand All @@ -23,6 +37,20 @@ export const actions = {
// Some local adapters do not expose a client address.
}

const turnstile = await verifyTurnstile(
String(form.get('cf-turnstile-response') ?? ''),
env.TURNSTILE_SECRET_KEY ?? '',
clientId === 'unknown' ? undefined : clientId,
fetch,
);
if (!turnstile.ok) {
console.warn('Turnstile rejected a submission:', turnstile.reason);
return fail(400, {
success: false,
message: 'Could not verify you are human. Please try again.',
});
}

const rateLimit = await checkRateLimit(clientId, platform?.env?.CONTACT_RATE_LIMIT);
if (!rateLimit.allowed) {
return fail(429, {
Expand Down
8 changes: 7 additions & 1 deletion src/routes/contact/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import SiteNav from '$lib/components/SiteNav.svelte';
import { siteNav, socials } from '$lib/home-data';

let { form }: PageProps = $props();
let { form, data }: PageProps = $props();
let sending = $state(false);
</script>

Expand All @@ -16,6 +16,9 @@
content="Get in touch with CJ Dyas about design work, collaboration, or roles."
/>
<link rel="canonical" href="https://cjdyas.design/contact" />
{#if data.turnstileSiteKey}
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
{/if}
</svelte:head>

<div class="page-shell">
Expand Down Expand Up @@ -81,6 +84,9 @@
rows="6"
placeholder="A few details about what you have in mind"></textarea>
</label>
{#if data.turnstileSiteKey}
<div class="cf-turnstile" data-sitekey={data.turnstileSiteKey} data-theme="auto"></div>
{/if}
<div class="form-actions">
<button class="btn primary" type="submit" disabled={sending}
>{sending ? 'Sending...' : form?.success ? 'Sent' : 'Send message'}</button
Expand Down
13 changes: 7 additions & 6 deletions tests/e2e/click-through.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@ async function clickNav(page: Page, label: string) {
await link.click();
}

// Vercel Analytics fetches its script from an external CDN that is not reachable in
// tests, which surfaces as an uncaught "Failed to fetch". Stub it so the assertion
// below can stay strict and still catch errors from our own code.
async function stubAnalytics(page: Page) {
await page.route(/va\.vercel-scripts\.com|_vercel\/insights/, (route) =>
// Turnstile loads from an external CDN and logs its own noise to console.error,
// and it will not issue a token to an automated browser anyway, which is rather
// the point of it. Stub it so the assertion below can stay strict and still
// catch errors from our own code.
async function stubThirdParty(page: Page) {
await page.route(/challenges\.cloudflare\.com/, (route) =>
route.fulfill({ status: 200, contentType: 'application/javascript', body: '' }),
);
}
Expand All @@ -33,7 +34,7 @@ function watchErrors(page: Page) {
}

test('every nav destination is reachable by clicking, from every page', async ({ page }) => {
await stubAnalytics(page);
await stubThirdParty(page);
const errors = watchErrors(page);
const starts = [
'/',
Expand Down
5 changes: 5 additions & 0 deletions wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
// Worker throws at runtime rather than at build time.
"compatibility_flags": ["nodejs_compat"],
"main": ".svelte-kit/cloudflare/_worker.js",
// Turnstile site keys are public by design; only the secret is a secret, and
// that lives in `wrangler secret`. Kept here so the CI builder has it too.
"vars": {
"PUBLIC_TURNSTILE_SITE_KEY": "0x4AAAAAAEDA2TbnR8IMZEeU",
},
// Keep the workers.dev hostname alive as a fallback that does not depend on
// the zone's DNS being correct.
"workers_dev": true,
Expand Down
Loading