feat: captcha

This commit is contained in:
Face 2025-06-24 19:37:34 +03:00
parent b79ee0c08a
commit 9a99f934b6
12 changed files with 184 additions and 11 deletions

View file

@ -8,6 +8,9 @@
import { Send, DollarSign, Coins, Loader2 } from 'lucide-svelte';
import { PORTFOLIO_DATA } from '$lib/stores/portfolio-data';
import { toast } from 'svelte-sonner';
import { Turnstile } from 'svelte-turnstile';
import { PUBLIC_TURNSTILE_SITE_KEY } from '$env/static/public';
import { page } from '$app/stores';
let {
open = $bindable(false),
@ -24,6 +27,9 @@
let amount = $state('');
let selectedCoinSymbol = $state('');
let loading = $state(false);
let turnstileToken = $state('');
let turnstileError = $state('');
let turnstileReset = $state<(() => void) | undefined>(undefined);
let numericAmount = $derived(parseFloat(amount) || 0);
let hasValidAmount = $derived(numericAmount > 0);
@ -57,6 +63,9 @@
let isWithinCoinValueLimit = $derived(transferType === 'COIN' ? estimatedValue >= 10 : true);
const turnstileVerified = $derived(!!$page.data?.turnstileVerified);
let optimisticTurnstileVerified = $state(false);
let canSend = $derived(
hasValidAmount &&
hasValidRecipient &&
@ -64,7 +73,8 @@
isWithinCashLimit &&
isWithinCoinValueLimit &&
!loading &&
(transferType === 'CASH' || selectedCoinSymbol.length > 0)
(transferType === 'CASH' || selectedCoinSymbol.length > 0) &&
(turnstileVerified || optimisticTurnstileVerified || !!turnstileToken)
);
function handleClose() {
@ -114,7 +124,8 @@
recipientUsername: recipientUsername.trim(),
type: transferType,
amount: numericAmount,
coinSymbol: transferType === 'COIN' ? selectedCoinSymbol : undefined
coinSymbol: transferType === 'COIN' ? selectedCoinSymbol : undefined,
turnstileToken
})
});
@ -141,6 +152,9 @@
onSuccess?.();
handleClose();
turnstileToken = '';
optimisticTurnstileVerified = true;
} catch (e) {
toast.error('Transfer failed', {
description: (e as Error).message
@ -325,6 +339,34 @@
</div>
</div>
{/if}
{#if !(turnstileVerified || optimisticTurnstileVerified)}
<div>
<Turnstile
siteKey={PUBLIC_TURNSTILE_SITE_KEY}
theme="auto"
size="normal"
bind:reset={turnstileReset}
on:callback={(e: CustomEvent<{ token: string }>) => {
turnstileToken = e.detail.token;
turnstileError = '';
}}
on:error={(e: CustomEvent<{ code: string }>) => {
turnstileToken = '';
turnstileError = e.detail.code || 'Captcha error';
}}
on:expired={() => {
turnstileToken = '';
turnstileError = 'Captcha expired';
}}
execution="render"
appearance="always"
/>
{#if turnstileError}
<p class="text-destructive mt-1 text-xs">{turnstileError}</p>
{/if}
</div>
{/if}
</div>
<Dialog.Footer class="flex gap-2">

View file

@ -7,6 +7,9 @@
import { TrendingUp, TrendingDown, Loader2 } from 'lucide-svelte';
import { PORTFOLIO_SUMMARY } from '$lib/stores/portfolio-data';
import { toast } from 'svelte-sonner';
import { Turnstile } from 'svelte-turnstile';
import { PUBLIC_TURNSTILE_SITE_KEY } from '$env/static/public';
import { page } from '$app/stores';
let {
open = $bindable(false),
@ -24,6 +27,8 @@
let amount = $state('');
let loading = $state(false);
let turnstileToken = $state('');
let turnstileError = $state('');
let numericAmount = $derived(parseFloat(amount) || 0);
let currentPrice = $derived(coin.currentPrice || 0);
@ -39,7 +44,14 @@
let hasEnoughFunds = $derived(
type === 'BUY' ? numericAmount <= userBalance : numericAmount <= userHolding
);
let canTrade = $derived(hasValidAmount && hasEnoughFunds && !loading);
const turnstileVerified = $derived(!!$page.data?.turnstileVerified);
let optimisticTurnstileVerified = $state(false);
let showCaptcha = $derived(!(turnstileVerified || optimisticTurnstileVerified));
let canTrade = $derived(
hasValidAmount && hasEnoughFunds && !loading && (!showCaptcha || !!turnstileToken)
);
function calculateEstimate(amount: number, tradeType: 'BUY' | 'SELL', price: number) {
if (!amount || !price || !coin) return { result: 0 };
@ -70,6 +82,8 @@
loading = false;
}
let turnstileReset = $state<(() => void) | undefined>(undefined);
async function handleTrade() {
if (!canTrade) return;
@ -82,7 +96,8 @@
},
body: JSON.stringify({
type,
amount: numericAmount
amount: numericAmount,
turnstileToken
})
});
@ -101,6 +116,9 @@
onSuccess?.();
handleClose();
turnstileToken = '';
optimisticTurnstileVerified = true;
} catch (e) {
toast.error('Trade failed', {
description: (e as Error).message
@ -194,6 +212,33 @@
{type === 'BUY' ? 'Insufficient funds' : 'Insufficient coins'}
</Badge>
{/if}
<div>
<Turnstile
siteKey={PUBLIC_TURNSTILE_SITE_KEY}
theme="auto"
size="normal"
bind:reset={turnstileReset}
on:callback={(e: CustomEvent<{ token: string }>) => {
turnstileToken = e.detail.token;
turnstileError = '';
}}
on:error={(e: CustomEvent<{ code: string }>) => {
turnstileToken = '';
turnstileError = e.detail.code || 'Captcha error';
}}
on:expired={() => {
turnstileToken = '';
turnstileError = 'Captcha expired';
}}
execution="render"
appearance="always"
/>
{#if turnstileError}
<p class="text-destructive mt-1 text-xs">{turnstileError}</p>
{/if}
</div>
{/if}
</div>
<Dialog.Footer class="flex gap-2">

View file

@ -15,3 +15,14 @@ if (!building) {
}
export { client as redis };
const TURNSTILE_PREFIX = 'turnstile:verified:';
const TURNSTILE_TTL = 5 * 60; // 5 minutes
export async function setTurnstileVerifiedRedis(userId: string) {
await client.set(`${TURNSTILE_PREFIX}${userId}`, '1', { EX: TURNSTILE_TTL });
}
export async function isTurnstileVerifiedRedis(userId: string): Promise<boolean> {
return !!(await client.get(`${TURNSTILE_PREFIX}${userId}`));
}

View file

@ -0,0 +1,20 @@
import { env } from '$env/dynamic/private';
const TURNSTILE_SECRET = env.TURNSTILE_SECRET_KEY;
export async function verifyTurnstile(token: string, request: Request): Promise<boolean> {
if (!TURNSTILE_SECRET) return false;
const ip = request.headers.get('x-forwarded-for') || request.headers.get('cf-connecting-ip') || undefined;
const body = new URLSearchParams({
secret: TURNSTILE_SECRET,
response: token,
...(ip ? { remoteip: ip } : {})
});
const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
body,
headers: { 'content-type': 'application/x-www-form-urlencoded' }
});
const data = await res.json();
return !!data.success;
}