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

@ -27,3 +27,8 @@ PUBLIC_B2_REGION=us-west-002
# OpenAI (for AI features) # OpenAI (for AI features)
OPENROUTER_API_KEY=your_openrouter_api_key OPENROUTER_API_KEY=your_openrouter_api_key
# Turnstile (for CAPTCHA)
# The default ones are for testing purposes only, and will accept any request.
PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA

View file

@ -28,7 +28,8 @@
"sharp": "^0.34.2", "sharp": "^0.34.2",
"svelte-apexcharts": "^1.0.2", "svelte-apexcharts": "^1.0.2",
"svelte-confetti": "^2.3.1", "svelte-confetti": "^2.3.1",
"svelte-lightweight-charts": "^2.2.0" "svelte-lightweight-charts": "^2.2.0",
"svelte-turnstile": "^0.11.0"
}, },
"devDependencies": { "devDependencies": {
"@internationalized/date": "^3.8.1", "@internationalized/date": "^3.8.1",
@ -5416,6 +5417,17 @@
"svelte": "^5.30.2" "svelte": "^5.30.2"
} }
}, },
"node_modules/svelte-turnstile": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/svelte-turnstile/-/svelte-turnstile-0.11.0.tgz",
"integrity": "sha512-2LFklx9JVsR3fJ7e3fGG1HEAWWEqRq1WfNaVrKgZJ+pzfY2NColiH+wH0kK2yX3DrcGLiJ9vBeTyiLFWotKpLA==",
"dependencies": {
"turnstile-types": "^1.2.3"
},
"peerDependencies": {
"svelte": "^3.58.0 || ^4.0.0 || ^5.0.0"
}
},
"node_modules/svg.draggable.js": { "node_modules/svg.draggable.js": {
"version": "2.2.2", "version": "2.2.2",
"license": "MIT", "license": "MIT",
@ -5578,6 +5590,11 @@
"version": "2.8.1", "version": "2.8.1",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/turnstile-types": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/turnstile-types/-/turnstile-types-1.2.3.tgz",
"integrity": "sha512-EDjhDB9TDwda2JRbhzO/kButPio3JgrC3gXMVAMotxldybTCJQVMvPNJ89rcAiN9vIrCb2i1E+VNBCqB8wue0A=="
},
"node_modules/tw-animate-css": { "node_modules/tw-animate-css": {
"version": "1.3.0", "version": "1.3.0",
"dev": true, "dev": true,

View file

@ -61,7 +61,8 @@
"sharp": "^0.34.2", "sharp": "^0.34.2",
"svelte-apexcharts": "^1.0.2", "svelte-apexcharts": "^1.0.2",
"svelte-confetti": "^2.3.1", "svelte-confetti": "^2.3.1",
"svelte-lightweight-charts": "^2.2.0" "svelte-lightweight-charts": "^2.2.0",
"svelte-turnstile": "^0.11.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "*" "@rollup/rollup-linux-x64-gnu": "*"

View file

@ -4,11 +4,13 @@ declare global {
namespace App { namespace App {
interface Locals { interface Locals {
userSession: User; userSession: User;
turnstileVerified?: boolean;
} }
interface PageData { interface PageData {
userSession: User; userSession: User;
turnstileVerified?: boolean;
} }
} }
} }
export {}; export { };

View file

@ -8,6 +8,7 @@ import { db } from '$lib/server/db';
import { user } from '$lib/server/db/schema'; import { user } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { minesCleanupInactiveGames, minesAutoCashout } from '$lib/server/games/mines'; import { minesCleanupInactiveGames, minesAutoCashout } from '$lib/server/games/mines';
import { isTurnstileVerifiedRedis } from '$lib/server/redis';
async function initializeScheduler() { async function initializeScheduler() {
if (building) return; if (building) return;
@ -179,6 +180,12 @@ export const handle: Handle = async ({ event, resolve }) => {
event.locals.userSession = userData; event.locals.userSession = userData;
if (session?.user?.id) {
event.locals.turnstileVerified = await isTurnstileVerifiedRedis(session.user.id);
} else {
event.locals.turnstileVerified = false;
}
if (event.url.pathname.startsWith('/api/')) { if (event.url.pathname.startsWith('/api/')) {
const response = await svelteKitHandler({ event, resolve, auth }); const response = await svelteKitHandler({ event, resolve, auth });
response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, private'); response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, private');

View file

@ -8,6 +8,9 @@
import { Send, DollarSign, Coins, Loader2 } from 'lucide-svelte'; import { Send, DollarSign, Coins, Loader2 } from 'lucide-svelte';
import { PORTFOLIO_DATA } from '$lib/stores/portfolio-data'; import { PORTFOLIO_DATA } from '$lib/stores/portfolio-data';
import { toast } from 'svelte-sonner'; 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 { let {
open = $bindable(false), open = $bindable(false),
@ -24,6 +27,9 @@
let amount = $state(''); let amount = $state('');
let selectedCoinSymbol = $state(''); let selectedCoinSymbol = $state('');
let loading = $state(false); let loading = $state(false);
let turnstileToken = $state('');
let turnstileError = $state('');
let turnstileReset = $state<(() => void) | undefined>(undefined);
let numericAmount = $derived(parseFloat(amount) || 0); let numericAmount = $derived(parseFloat(amount) || 0);
let hasValidAmount = $derived(numericAmount > 0); let hasValidAmount = $derived(numericAmount > 0);
@ -57,6 +63,9 @@
let isWithinCoinValueLimit = $derived(transferType === 'COIN' ? estimatedValue >= 10 : true); let isWithinCoinValueLimit = $derived(transferType === 'COIN' ? estimatedValue >= 10 : true);
const turnstileVerified = $derived(!!$page.data?.turnstileVerified);
let optimisticTurnstileVerified = $state(false);
let canSend = $derived( let canSend = $derived(
hasValidAmount && hasValidAmount &&
hasValidRecipient && hasValidRecipient &&
@ -64,7 +73,8 @@
isWithinCashLimit && isWithinCashLimit &&
isWithinCoinValueLimit && isWithinCoinValueLimit &&
!loading && !loading &&
(transferType === 'CASH' || selectedCoinSymbol.length > 0) (transferType === 'CASH' || selectedCoinSymbol.length > 0) &&
(turnstileVerified || optimisticTurnstileVerified || !!turnstileToken)
); );
function handleClose() { function handleClose() {
@ -114,7 +124,8 @@
recipientUsername: recipientUsername.trim(), recipientUsername: recipientUsername.trim(),
type: transferType, type: transferType,
amount: numericAmount, amount: numericAmount,
coinSymbol: transferType === 'COIN' ? selectedCoinSymbol : undefined coinSymbol: transferType === 'COIN' ? selectedCoinSymbol : undefined,
turnstileToken
}) })
}); });
@ -141,6 +152,9 @@
onSuccess?.(); onSuccess?.();
handleClose(); handleClose();
turnstileToken = '';
optimisticTurnstileVerified = true;
} catch (e) { } catch (e) {
toast.error('Transfer failed', { toast.error('Transfer failed', {
description: (e as Error).message description: (e as Error).message
@ -325,6 +339,34 @@
</div> </div>
</div> </div>
{/if} {/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> </div>
<Dialog.Footer class="flex gap-2"> <Dialog.Footer class="flex gap-2">

View file

@ -7,6 +7,9 @@
import { TrendingUp, TrendingDown, Loader2 } from 'lucide-svelte'; import { TrendingUp, TrendingDown, Loader2 } from 'lucide-svelte';
import { PORTFOLIO_SUMMARY } from '$lib/stores/portfolio-data'; import { PORTFOLIO_SUMMARY } from '$lib/stores/portfolio-data';
import { toast } from 'svelte-sonner'; 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 { let {
open = $bindable(false), open = $bindable(false),
@ -24,6 +27,8 @@
let amount = $state(''); let amount = $state('');
let loading = $state(false); let loading = $state(false);
let turnstileToken = $state('');
let turnstileError = $state('');
let numericAmount = $derived(parseFloat(amount) || 0); let numericAmount = $derived(parseFloat(amount) || 0);
let currentPrice = $derived(coin.currentPrice || 0); let currentPrice = $derived(coin.currentPrice || 0);
@ -39,7 +44,14 @@
let hasEnoughFunds = $derived( let hasEnoughFunds = $derived(
type === 'BUY' ? numericAmount <= userBalance : numericAmount <= userHolding 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) { function calculateEstimate(amount: number, tradeType: 'BUY' | 'SELL', price: number) {
if (!amount || !price || !coin) return { result: 0 }; if (!amount || !price || !coin) return { result: 0 };
@ -70,6 +82,8 @@
loading = false; loading = false;
} }
let turnstileReset = $state<(() => void) | undefined>(undefined);
async function handleTrade() { async function handleTrade() {
if (!canTrade) return; if (!canTrade) return;
@ -82,7 +96,8 @@
}, },
body: JSON.stringify({ body: JSON.stringify({
type, type,
amount: numericAmount amount: numericAmount,
turnstileToken
}) })
}); });
@ -101,6 +116,9 @@
onSuccess?.(); onSuccess?.();
handleClose(); handleClose();
turnstileToken = '';
optimisticTurnstileVerified = true;
} catch (e) { } catch (e) {
toast.error('Trade failed', { toast.error('Trade failed', {
description: (e as Error).message description: (e as Error).message
@ -194,6 +212,33 @@
{type === 'BUY' ? 'Insufficient funds' : 'Insufficient coins'} {type === 'BUY' ? 'Insufficient funds' : 'Insufficient coins'}
</Badge> </Badge>
{/if} {/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> </div>
<Dialog.Footer class="flex gap-2"> <Dialog.Footer class="flex gap-2">

View file

@ -15,3 +15,14 @@ if (!building) {
} }
export { client as redis }; 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;
}

View file

@ -12,5 +12,6 @@ export const load: LayoutServerLoad = async (event) => {
return { return {
userSession: event.locals.userSession, userSession: event.locals.userSession,
url: event.url.pathname, url: event.url.pathname,
turnstileVerified: event.locals.turnstileVerified ?? false
}; };
}; };

View file

@ -5,6 +5,8 @@ import { coin, userPortfolio, user, transaction, priceHistory } from '$lib/serve
import { eq, and, gte } from 'drizzle-orm'; import { eq, and, gte } from 'drizzle-orm';
import { redis } from '$lib/server/redis'; import { redis } from '$lib/server/redis';
import { createNotification } from '$lib/server/notification'; import { createNotification } from '$lib/server/notification';
import { verifyTurnstile } from '$lib/server/turnstile';
import { setTurnstileVerifiedRedis, isTurnstileVerifiedRedis } from '$lib/server/redis';
async function calculate24hMetrics(coinId: number, currentPrice: number) { async function calculate24hMetrics(coinId: number, currentPrice: number) {
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
@ -53,7 +55,16 @@ export async function POST({ params, request }) {
} }
const { coinSymbol } = params; const { coinSymbol } = params;
const { type, amount } = await request.json(); const { type, amount, turnstileToken } = await request.json();
const alreadyVerified = await isTurnstileVerifiedRedis(session.user.id);
if (!alreadyVerified) {
if (!turnstileToken || !(await verifyTurnstile(turnstileToken, request))) {
throw error(400, 'Captcha verification failed');
}
await setTurnstileVerifiedRedis(session.user.id);
}
if (!['BUY', 'SELL'].includes(type)) { if (!['BUY', 'SELL'].includes(type)) {
throw error(400, 'Invalid transaction type'); throw error(400, 'Invalid transaction type');

View file

@ -5,6 +5,8 @@ import { user, userPortfolio, coin, transaction } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import { createNotification } from '$lib/server/notification'; import { createNotification } from '$lib/server/notification';
import { formatValue } from '$lib/utils'; import { formatValue } from '$lib/utils';
import { verifyTurnstile } from '$lib/server/turnstile';
import { setTurnstileVerifiedRedis, isTurnstileVerifiedRedis } from '$lib/server/redis';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
interface TransferRequest { interface TransferRequest {
@ -22,7 +24,16 @@ export const POST: RequestHandler = async ({ request }) => {
if (!session?.user) { if (!session?.user) {
throw error(401, 'Not authenticated'); throw error(401, 'Not authenticated');
} try { } try {
const { recipientUsername, type, amount, coinSymbol }: TransferRequest = await request.json(); const { recipientUsername, type, amount, coinSymbol, turnstileToken }: TransferRequest & { turnstileToken?: string } = await request.json();
const alreadyVerified = await isTurnstileVerifiedRedis(session.user.id);
if (!alreadyVerified) {
if (!turnstileToken || !(await verifyTurnstile(turnstileToken, request))) {
throw error(400, 'Captcha verification failed');
}
await setTurnstileVerifiedRedis(session.user.id);
}
if (!recipientUsername || !type || !amount || typeof amount !== 'number' || !Number.isFinite(amount) || amount <= 0) { if (!recipientUsername || !type || !amount || typeof amount !== 'number' || !Number.isFinite(amount) || amount <= 0) {
throw error(400, 'Invalid transfer parameters'); throw error(400, 'Invalid transfer parameters');