feat: add Cloudflare Turnstile verification endpoint and integrate with frontend

This commit is contained in:
Zack
2026-03-23 16:26:49 +00:00
parent 3693777674
commit 8f93a388c9
12 changed files with 119 additions and 6 deletions
+4 -1
View File
@@ -1,5 +1,8 @@
node_modules
# IDEs
.idea
# Output
.output
.vercel
@@ -23,4 +26,4 @@ vite.config.ts.timestamp-*
# Planning
.planning/
apps/*/.planning
apps/*/.planning
@@ -0,0 +1,5 @@
-- migrations/0003_turnstile_verified.sql
-- Track whether the respondent passed a Cloudflare Turnstile challenge.
-- Existing respondents default to unverified (0).
ALTER TABLE respondents ADD COLUMN is_verified INTEGER DEFAULT 0;
@@ -9,6 +9,48 @@ const router = AutoRouter<IRequest, [Env, ExecutionContext]>({
finally: [corsify],
});
router.post('/api/verify', async (request, env) => {
const db = env.DB;
const { turnstileToken } = (await request.json()) as { turnstileToken: string };
if (!turnstileToken) {
return new Response('Missing turnstile token', { status: 400 });
}
// dev environment — skip Cloudflare call
if (env.CF_TURNSTILE_SECRET !== 'DEV_TURNSTILE_TOKEN') {
const ip = request.headers.get('CF-Connecting-IP');
if (!ip) {
return new Response('Missing client IP', { status: 400 });
}
const formData = new FormData();
formData.append('secret', env.CF_TURNSTILE_SECRET);
formData.append('response', turnstileToken);
formData.append('remoteip', ip);
const result = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
body: formData,
method: 'POST',
});
const outcome = (await result.json()) as { success: boolean };
if (!outcome.success) {
return new Response('Turnstile validation failed', { status: 403 });
}
}
const respondentId = getRespondentId(request);
if (respondentId) {
await db
.prepare('UPDATE respondents SET is_verified = 1 WHERE id = ?')
.bind(respondentId)
.run();
}
return Response.json({ success: true });
});
router.post('/api/answers', async (request, env) => {
const db = env.DB;
const { questionId, value, otherText } = (await request.json()) as {
@@ -71,9 +113,9 @@ router.get('/api/resume', async (request, env) => {
}
const respondent = await db
.prepare('SELECT id, is_complete FROM respondents WHERE id = ?')
.prepare('SELECT id, is_complete, is_verified FROM respondents WHERE id = ?')
.bind(respondentId)
.first<{ id: string; is_complete: number }>();
.first<{ id: string; is_complete: number; is_verified: number }>();
if (!respondent) {
return Response.json({ answers: {}, nextQuestionIndex: 0 }, { headers });
@@ -113,7 +155,7 @@ router.get('/api/resume', async (request, env) => {
}
}
return Response.json({ answers, nextQuestionIndex }, { headers });
return Response.json({ answers, nextQuestionIndex, isVerified: !!respondent.is_verified }, { headers });
});
router.post('/api/complete', async (request, env) => {
@@ -1,3 +1,4 @@
interface Env {
DB: D1Database;
CF_TURNSTILE_SECRET: string;
}
@@ -6,6 +6,9 @@
"observability": {
"enabled": true
},
"vars": {
"CF_TURNSTILE_SECRET": "DEV_TURNSTILE_TOKEN"
},
"d1_databases": [
{
"binding": "DB",
@@ -24,6 +24,7 @@
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.1.13",
"@types/node": "^24.12.0",
"dotenv": "^17.3.1",
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.12.4",
@@ -41,6 +42,7 @@
},
"dependencies": {
"@immich/ui": "^0.65.0",
"@mdi/js": "^7.4.47"
"@mdi/js": "^7.4.47",
"svelte-turnstile": "^0.11.0"
}
}
@@ -1,5 +1,20 @@
import type { SurveyAnswer } from './types';
/**
* Verifies a Cloudflare Turnstile token with the backend.
*/
export async function verifyTurnstile(turnstileToken: string): Promise<void> {
const res = await fetch('/api/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turnstileToken }),
credentials: 'same-origin',
});
if (!res.ok) {
throw new Error('Challenge verification failed. Please try again.');
}
}
interface PendingSave {
questionId: string;
value: string;
@@ -84,6 +99,7 @@ export async function fetchResume(): Promise<{
answers?: Record<string, { value: string; otherText?: string }>;
nextQuestionIndex?: number;
isComplete?: boolean;
isVerified?: boolean;
}> {
const res = await fetch('/api/resume', { credentials: 'same-origin' });
if (!res.ok) {
@@ -1,5 +1,8 @@
<script lang="ts">
import { PUBLIC_CF_TURNSTILE_SITE } from '$env/static/public';
import { Button, Logo } from '@immich/ui';
import { Turnstile } from 'svelte-turnstile';
import { verifyTurnstile } from '$lib/api-client';
interface Props {
onStart: () => void;
@@ -32,5 +35,11 @@
</ol>
</div>
<Turnstile
siteKey={PUBLIC_CF_TURNSTILE_SITE}
on:callback={(e) => verifyTurnstile(e.detail.token).catch(() => {})}
on:error={() => {}}
/>
<Button color="primary" onclick={onStart}>Get Started</Button>
</div>
@@ -1,7 +1,9 @@
<script lang="ts">
import { PUBLIC_CF_TURNSTILE_SITE } from '$env/static/public';
import { onMount } from 'svelte';
import { Turnstile } from 'svelte-turnstile';
import { createSurveyEngine } from '$lib/survey-engine.svelte';
import { fetchResume, fireAndForgetSave, postComplete, onSaveError } from '$lib/api-client';
import { fetchResume, fireAndForgetSave, postComplete, onSaveError, verifyTurnstile } from '$lib/api-client';
import SurveyShell from '$lib/components/SurveyShell.svelte';
import ThankYouScreen from '$lib/components/ThankYouScreen.svelte';
import AlreadyCompleted from '$lib/components/AlreadyCompleted.svelte';
@@ -12,18 +14,25 @@
let alreadyCompleted = $state(false);
let surveyFinished = $state(false);
let showWelcome = $state(false);
let needsVerification = $state(false);
let error = $state<string | null>(null);
onSaveError((message) => {
error = message;
});
function onTurnstileCallback(token: string) {
needsVerification = false;
verifyTurnstile(token).catch(() => {});
}
onMount(async () => {
try {
const resume = await fetchResume();
if (resume.isComplete) {
alreadyCompleted = true;
} else if (resume.answers && resume.nextQuestionIndex !== undefined && resume.nextQuestionIndex > 0) {
needsVerification = !resume.isVerified;
engine.initialize(resume.answers, resume.nextQuestionIndex);
} else {
showWelcome = true;
@@ -76,5 +85,12 @@
{:else if showWelcome}
<WelcomeScreen onStart={() => (showWelcome = false)} />
{:else}
{#if needsVerification}
<Turnstile
siteKey={PUBLIC_CF_TURNSTILE_SITE}
on:callback={(e) => onTurnstileCallback(e.detail.token)}
on:error={() => (needsVerification = false)}
/>
{/if}
<SurveyShell {engine} onAnswer={handleAnswer} onComplete={handleComplete} />
{/if}
@@ -1,7 +1,12 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
import dotenv from 'dotenv';
import { resolve } from 'node:path';
dotenv.config({ path: '../../.env' });
process.env.PUBLIC_CF_TURNSTILE_SITE = process.env.PUBLIC_CF_TURNSTILE_SITE || '1x00000000000000000000BB';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
@@ -5,6 +5,11 @@ resource "cloudflare_workers_script" "api" {
compatibility_date = "2025-06-03"
module = true
secret_text_binding {
name = "CF_TURNSTILE_SECRET"
text = data.terraform_remote_state.cloudflare_account.outputs.turnstile_default_invisible_secret
}
d1_database_binding {
name = "DB"
database_id = cloudflare_d1_database.survey.id
+6
View File
@@ -445,6 +445,9 @@ importers:
'@mdi/js':
specifier: ^7.4.47
version: 7.4.47
svelte-turnstile:
specifier: ^0.11.0
version: 0.11.0(svelte@5.53.11)
devDependencies:
'@eslint/js':
specifier: ^10.0.0
@@ -464,6 +467,9 @@ importers:
'@types/node':
specifier: ^24.12.0
version: 24.12.0
dotenv:
specifier: ^17.3.1
version: 17.3.1
eslint:
specifier: ^10.0.0
version: 10.0.3(jiti@2.6.1)