From 8f93a388c9e56460de5d6269f8b6bf0a198a347b Mon Sep 17 00:00:00 2001 From: Zack Date: Mon, 23 Mar 2026 13:06:46 +0000 Subject: [PATCH] feat: add Cloudflare Turnstile verification endpoint and integrate with frontend --- .gitignore | 5 +- .../migrations/0003_turnstile_verified.sql | 5 ++ .../backend/src/index.ts | 48 +++++++++++++++++-- .../backend/worker-configuration.d.ts | 1 + .../backend/wrangler.jsonc | 3 ++ .../package.json | 4 +- .../src/lib/api-client.ts | 16 +++++++ .../src/lib/components/WelcomeScreen.svelte | 9 ++++ .../src/routes/+page.svelte | 18 ++++++- .../svelte.config.js | 5 ++ .../workers/futo-backups-survey/worker.tf | 5 ++ pnpm-lock.yaml | 6 +++ 12 files changed, 119 insertions(+), 6 deletions(-) create mode 100644 apps/futo-backups-survey.immich.app/backend/migrations/0003_turnstile_verified.sql diff --git a/.gitignore b/.gitignore index df99c0d7..f8beb415 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ node_modules +# IDEs +.idea + # Output .output .vercel @@ -23,4 +26,4 @@ vite.config.ts.timestamp-* # Planning .planning/ -apps/*/.planning \ No newline at end of file +apps/*/.planning diff --git a/apps/futo-backups-survey.immich.app/backend/migrations/0003_turnstile_verified.sql b/apps/futo-backups-survey.immich.app/backend/migrations/0003_turnstile_verified.sql new file mode 100644 index 00000000..806f4339 --- /dev/null +++ b/apps/futo-backups-survey.immich.app/backend/migrations/0003_turnstile_verified.sql @@ -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; diff --git a/apps/futo-backups-survey.immich.app/backend/src/index.ts b/apps/futo-backups-survey.immich.app/backend/src/index.ts index 155aacd2..32be2702 100644 --- a/apps/futo-backups-survey.immich.app/backend/src/index.ts +++ b/apps/futo-backups-survey.immich.app/backend/src/index.ts @@ -9,6 +9,48 @@ const router = AutoRouter({ 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) => { diff --git a/apps/futo-backups-survey.immich.app/backend/worker-configuration.d.ts b/apps/futo-backups-survey.immich.app/backend/worker-configuration.d.ts index 19a4385a..aedf717f 100644 --- a/apps/futo-backups-survey.immich.app/backend/worker-configuration.d.ts +++ b/apps/futo-backups-survey.immich.app/backend/worker-configuration.d.ts @@ -1,3 +1,4 @@ interface Env { DB: D1Database; + CF_TURNSTILE_SECRET: string; } diff --git a/apps/futo-backups-survey.immich.app/backend/wrangler.jsonc b/apps/futo-backups-survey.immich.app/backend/wrangler.jsonc index d2dc9ca7..3e852ba5 100644 --- a/apps/futo-backups-survey.immich.app/backend/wrangler.jsonc +++ b/apps/futo-backups-survey.immich.app/backend/wrangler.jsonc @@ -6,6 +6,9 @@ "observability": { "enabled": true }, + "vars": { + "CF_TURNSTILE_SECRET": "DEV_TURNSTILE_TOKEN" + }, "d1_databases": [ { "binding": "DB", diff --git a/apps/futo-backups-survey.immich.app/package.json b/apps/futo-backups-survey.immich.app/package.json index 8a964b97..55086feb 100644 --- a/apps/futo-backups-survey.immich.app/package.json +++ b/apps/futo-backups-survey.immich.app/package.json @@ -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" } } diff --git a/apps/futo-backups-survey.immich.app/src/lib/api-client.ts b/apps/futo-backups-survey.immich.app/src/lib/api-client.ts index 0b2d177a..e86c8dcb 100644 --- a/apps/futo-backups-survey.immich.app/src/lib/api-client.ts +++ b/apps/futo-backups-survey.immich.app/src/lib/api-client.ts @@ -1,5 +1,20 @@ import type { SurveyAnswer } from './types'; +/** + * Verifies a Cloudflare Turnstile token with the backend. + */ +export async function verifyTurnstile(turnstileToken: string): Promise { + 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; nextQuestionIndex?: number; isComplete?: boolean; + isVerified?: boolean; }> { const res = await fetch('/api/resume', { credentials: 'same-origin' }); if (!res.ok) { diff --git a/apps/futo-backups-survey.immich.app/src/lib/components/WelcomeScreen.svelte b/apps/futo-backups-survey.immich.app/src/lib/components/WelcomeScreen.svelte index 59dc0f60..9e801bc0 100644 --- a/apps/futo-backups-survey.immich.app/src/lib/components/WelcomeScreen.svelte +++ b/apps/futo-backups-survey.immich.app/src/lib/components/WelcomeScreen.svelte @@ -1,5 +1,8 @@