mirror of
https://github.com/immich-app/static-pages.git
synced 2026-09-30 13:23:05 +08:00
feat: add Cloudflare Turnstile verification endpoint and integrate with frontend
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
node_modules
|
||||
|
||||
# IDEs
|
||||
.idea
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+6
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user