diff --git a/website/.env.example b/website/.env.example index 50db989..f04ec89 100644 --- a/website/.env.example +++ b/website/.env.example @@ -26,4 +26,9 @@ PUBLIC_B2_ENDPOINT=https://s3.us-west-002.backblazeb2.com PUBLIC_B2_REGION=us-west-002 # OpenAI (for AI features) -OPENROUTER_API_KEY=your_openrouter_api_key \ No newline at end of file +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 \ No newline at end of file diff --git a/website/package-lock.json b/website/package-lock.json index c5b45f5..f855751 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -28,7 +28,8 @@ "sharp": "^0.34.2", "svelte-apexcharts": "^1.0.2", "svelte-confetti": "^2.3.1", - "svelte-lightweight-charts": "^2.2.0" + "svelte-lightweight-charts": "^2.2.0", + "svelte-turnstile": "^0.11.0" }, "devDependencies": { "@internationalized/date": "^3.8.1", @@ -5416,6 +5417,17 @@ "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": { "version": "2.2.2", "license": "MIT", @@ -5578,6 +5590,11 @@ "version": "2.8.1", "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": { "version": "1.3.0", "dev": true, diff --git a/website/package.json b/website/package.json index efacfac..ea17787 100644 --- a/website/package.json +++ b/website/package.json @@ -61,7 +61,8 @@ "sharp": "^0.34.2", "svelte-apexcharts": "^1.0.2", "svelte-confetti": "^2.3.1", - "svelte-lightweight-charts": "^2.2.0" + "svelte-lightweight-charts": "^2.2.0", + "svelte-turnstile": "^0.11.0" }, "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "*" diff --git a/website/src/app.d.ts b/website/src/app.d.ts index d9fc4c3..584973d 100644 --- a/website/src/app.d.ts +++ b/website/src/app.d.ts @@ -4,11 +4,13 @@ declare global { namespace App { interface Locals { userSession: User; + turnstileVerified?: boolean; } interface PageData { userSession: User; + turnstileVerified?: boolean; } } } -export {}; +export { }; diff --git a/website/src/hooks.server.ts b/website/src/hooks.server.ts index 8a37675..f988aa8 100644 --- a/website/src/hooks.server.ts +++ b/website/src/hooks.server.ts @@ -8,6 +8,7 @@ import { db } from '$lib/server/db'; import { user } from '$lib/server/db/schema'; import { eq } from 'drizzle-orm'; import { minesCleanupInactiveGames, minesAutoCashout } from '$lib/server/games/mines'; +import { isTurnstileVerifiedRedis } from '$lib/server/redis'; async function initializeScheduler() { if (building) return; @@ -113,7 +114,7 @@ export const handle: Handle = async ({ event, resolve }) => { const userId = session.user.id; const cacheKey = `user:${userId}`; const now = Date.now(); - + const cached = sessionCache.get(cacheKey); if (cached && (now - cached.timestamp) < cached.ttl) { userData = cached.userData; @@ -179,6 +180,12 @@ export const handle: Handle = async ({ event, resolve }) => { 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/')) { const response = await svelteKitHandler({ event, resolve, auth }); response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, private'); diff --git a/website/src/lib/components/self/SendMoneyModal.svelte b/website/src/lib/components/self/SendMoneyModal.svelte index 6559edf..ca238b1 100644 --- a/website/src/lib/components/self/SendMoneyModal.svelte +++ b/website/src/lib/components/self/SendMoneyModal.svelte @@ -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 @@ {/if} + + {#if !(turnstileVerified || optimisticTurnstileVerified)} +
+ ) => { + 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} +

{turnstileError}

+ {/if} +
+ {/if} diff --git a/website/src/lib/components/self/TradeModal.svelte b/website/src/lib/components/self/TradeModal.svelte index 9c323dd..8e4de0a 100644 --- a/website/src/lib/components/self/TradeModal.svelte +++ b/website/src/lib/components/self/TradeModal.svelte @@ -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'} {/if} + +
+ ) => { + 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} +

{turnstileError}

+ {/if} +
+ {/if} diff --git a/website/src/lib/server/redis.ts b/website/src/lib/server/redis.ts index ba6ace6..09533ef 100644 --- a/website/src/lib/server/redis.ts +++ b/website/src/lib/server/redis.ts @@ -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 { + return !!(await client.get(`${TURNSTILE_PREFIX}${userId}`)); +} diff --git a/website/src/lib/server/turnstile.ts b/website/src/lib/server/turnstile.ts new file mode 100644 index 0000000..65acd8c --- /dev/null +++ b/website/src/lib/server/turnstile.ts @@ -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 { + 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; +} diff --git a/website/src/routes/+layout.server.ts b/website/src/routes/+layout.server.ts index 4f62bd0..b9fcbf3 100644 --- a/website/src/routes/+layout.server.ts +++ b/website/src/routes/+layout.server.ts @@ -12,5 +12,6 @@ export const load: LayoutServerLoad = async (event) => { return { userSession: event.locals.userSession, url: event.url.pathname, + turnstileVerified: event.locals.turnstileVerified ?? false }; }; \ No newline at end of file diff --git a/website/src/routes/api/coin/[coinSymbol]/trade/+server.ts b/website/src/routes/api/coin/[coinSymbol]/trade/+server.ts index 821e93e..d5001f0 100644 --- a/website/src/routes/api/coin/[coinSymbol]/trade/+server.ts +++ b/website/src/routes/api/coin/[coinSymbol]/trade/+server.ts @@ -5,6 +5,8 @@ import { coin, userPortfolio, user, transaction, priceHistory } from '$lib/serve import { eq, and, gte } from 'drizzle-orm'; import { redis } from '$lib/server/redis'; 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) { const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); @@ -53,7 +55,16 @@ export async function POST({ params, request }) { } 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)) { throw error(400, 'Invalid transaction type'); diff --git a/website/src/routes/api/transfer/+server.ts b/website/src/routes/api/transfer/+server.ts index 726d345..f200e16 100644 --- a/website/src/routes/api/transfer/+server.ts +++ b/website/src/routes/api/transfer/+server.ts @@ -5,6 +5,8 @@ import { user, userPortfolio, coin, transaction } from '$lib/server/db/schema'; import { eq, and } from 'drizzle-orm'; import { createNotification } from '$lib/server/notification'; import { formatValue } from '$lib/utils'; +import { verifyTurnstile } from '$lib/server/turnstile'; +import { setTurnstileVerifiedRedis, isTurnstileVerifiedRedis } from '$lib/server/redis'; import type { RequestHandler } from './$types'; interface TransferRequest { @@ -22,7 +24,16 @@ export const POST: RequestHandler = async ({ request }) => { if (!session?.user) { throw error(401, 'Not authenticated'); } 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) { throw error(400, 'Invalid transfer parameters');