diff --git a/.env.example b/.env.example index 77374de..89159a0 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,13 @@ +# Spotify API CLIENT_ID= CLIENT_SECRET= + +# Session token salt SESH_SECRET= + +# SQLite DB location DATABASE_URL=local.db + +# Rate limit Redis instance on upstash +UPSTASH_REDIS_URL="" +UPSTASH_REDIS_TOKEN="" diff --git a/package.json b/package.json index a8a76ad..219502f 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,8 @@ "dependencies": { "@fontsource-variable/kode-mono": "^5.1.1", "@fontsource-variable/smooch-sans": "^5.1.1", + "@upstash/ratelimit": "^2.0.5", + "@upstash/redis": "^1.34.4", "better-sqlite3": "^11.8.0", "drizzle-orm": "^0.38.4", "nanoid": "^5.0.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dbbfd30..179c8d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,12 @@ importers: '@fontsource-variable/smooch-sans': specifier: ^5.1.1 version: 5.1.1 + '@upstash/ratelimit': + specifier: ^2.0.5 + version: 2.0.5(@upstash/redis@1.34.4) + '@upstash/redis': + specifier: ^1.34.4 + version: 1.34.4 better-sqlite3: specifier: ^11.8.0 version: 11.8.1 @@ -1125,6 +1131,18 @@ packages: resolution: {integrity: sha512-AWpYAXnUgvLNabGTy3uBylkgZoosva/miNd1I8Bz3SjotmQPbVqhO4Cczo8AsZ44XVErEBPr/CRSgaj8sG7g0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@upstash/core-analytics@0.0.10': + resolution: {integrity: sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ==} + engines: {node: '>=16.0.0'} + + '@upstash/ratelimit@2.0.5': + resolution: {integrity: sha512-1FRv0cs3ZlBjCNOCpCmKYmt9BYGIJf0J0R3pucOPE88R21rL7jNjXG+I+rN/BVOvYJhI9niRAS/JaSNjiSICxA==} + peerDependencies: + '@upstash/redis': ^1.34.3 + + '@upstash/redis@1.34.4': + resolution: {integrity: sha512-AZx2iD5s1Pu/KCrRA7KVCffu3NSoaYnNY7N9YI7aLAYhcJfsriQKTe+8OxQWJqGqFbrvm17Lyr9HFnDLvqNpfA==} + '@vinejs/compiler@3.0.0': resolution: {integrity: sha512-v9Lsv59nR56+bmy2p0+czjZxsLHwaibJ+SV5iK9JJfehlJMa501jUJQqqz4X/OqKXrxtE3uTQmSqjUqzF3B2mw==} engines: {node: '>=18.0.0'} @@ -1334,6 +1352,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crypto-js@4.2.0: + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -3485,6 +3506,19 @@ snapshots: '@typescript-eslint/types': 8.22.0 eslint-visitor-keys: 4.2.0 + '@upstash/core-analytics@0.0.10': + dependencies: + '@upstash/redis': 1.34.4 + + '@upstash/ratelimit@2.0.5(@upstash/redis@1.34.4)': + dependencies: + '@upstash/core-analytics': 0.0.10 + '@upstash/redis': 1.34.4 + + '@upstash/redis@1.34.4': + dependencies: + crypto-js: 4.2.0 + '@vinejs/compiler@3.0.0': optional: true @@ -3708,6 +3742,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crypto-js@4.2.0: {} + cssesc@3.0.0: {} dayjs@1.11.13: diff --git a/src/lib/server/redis.ts b/src/lib/server/redis.ts new file mode 100644 index 0000000..e699e9f --- /dev/null +++ b/src/lib/server/redis.ts @@ -0,0 +1,58 @@ +import { Redis } from '@upstash/redis'; +import { Ratelimit } from '@upstash/ratelimit'; +import { UPSTASH_REDIS_TOKEN, UPSTASH_REDIS_URL } from '$env/static/private'; + +const redis = new Redis({ + url: UPSTASH_REDIS_URL, + token: UPSTASH_REDIS_TOKEN +}); + +export const ratelimit = { + noSesh: new Ratelimit({ + redis, + prefix: 'ratelimit:noSesh', + limiter: Ratelimit.slidingWindow(1, '1m'), + enableProtection: true + }), + pakubiiti: new Ratelimit({ + redis, + prefix: 'ratelimit:pakubiiti', + limiter: Ratelimit.slidingWindow(5, '15s'), + enableProtection: true + }), + pakubiitiIP: new Ratelimit({ + redis, + prefix: 'ratelimit:pakubiitiIP', + limiter: Ratelimit.slidingWindow(5, '15s'), + enableProtection: true + }), + rahvaAnswer: new Ratelimit({ + redis, + prefix: 'ratelimit:rahvaAnswer', + limiter: Ratelimit.slidingWindow(8, '15s'), + enableProtection: true + }), + rahvaAnswerIP: new Ratelimit({ + redis, + prefix: 'ratelimit:rahvaAnswerIP', + limiter: Ratelimit.slidingWindow(8, '15s'), + enableProtection: true + }), + rahvaQuestion: new Ratelimit({ + redis, + prefix: 'ratelimit:rahvaQuestion', + limiter: Ratelimit.slidingWindow(8, '15s'), + enableProtection: true + }), + rahvaQuestionIP: new Ratelimit({ + redis, + prefix: 'ratelimit:rahvaQuestionIP', + limiter: Ratelimit.slidingWindow(8, '15s'), + enableProtection: true + }), + rahvaQuestionAPI: new Ratelimit({ + redis, + prefix: 'ratelimit:rahvaQuestionAPI', + limiter: Ratelimit.slidingWindow(15, '15s') + }) +}; diff --git a/src/routes/api/rahvatarkus/answer/+server.ts b/src/routes/api/rahvatarkus/answer/+server.ts index b7fbd7d..d047003 100644 --- a/src/routes/api/rahvatarkus/answer/+server.ts +++ b/src/routes/api/rahvatarkus/answer/+server.ts @@ -5,6 +5,7 @@ import { eq, sql } from 'drizzle-orm'; import { db } from '$lib/server/db'; import { questions, answers } from '$lib/server/db/schema'; import { questionBalanceStore } from '$lib/server/rahvatarkus/QuestionBalance'; +import { ratelimit } from '$lib/server/redis'; const maxAnswers = 5; @@ -19,6 +20,15 @@ export async function POST({ locals, request }) { return json({ error: 'Unauthorized' }, { status: 401 }); } + const { success, reset } = await ratelimit.rahvaAnswer.limit(user); + + if (!success) { + const timeRemaining = Math.floor((reset - Date.now()) / 1000); + const message = `Võta veits rahulikumalt. Proovi ${timeRemaining}s pärast uuesti.`; + + return json({ error: message }, { status: 429 }); + } + // Start transaction return await db.transaction(async (tx) => { // Get question and validate in one query diff --git a/src/routes/api/rahvatarkus/question/+server.ts b/src/routes/api/rahvatarkus/question/+server.ts index ae21324..854318b 100644 --- a/src/routes/api/rahvatarkus/question/+server.ts +++ b/src/routes/api/rahvatarkus/question/+server.ts @@ -5,6 +5,7 @@ import { eq, and, not, sql, exists, lt, gt } from 'drizzle-orm'; import { db } from '$lib/server/db'; import { questions, answers } from '$lib/server/db/schema'; import { questionBalanceStore } from '$lib/server/rahvatarkus/QuestionBalance'; +import { ratelimit } from '$lib/server/redis'; export async function GET({ locals }) { const { session } = locals; @@ -12,6 +13,15 @@ export async function GET({ locals }) { const user = session.data.userId; + const { success, reset } = await ratelimit.rahvaQuestionAPI.limit(user); + + if (!success) { + const timeRemaining = Math.floor((reset - Date.now()) / 1000); + const message = `Võta veits rahulikumalt. Proovi ${timeRemaining}s pärast uuesti.`; + + return json({ error: message }, { status: 429 }); + } + // Use the answerCount field and avoid joins const eligibleQuestions = await db .select({ @@ -55,6 +65,15 @@ export async function POST({ locals, request }) { return json({ error: 'Unauthorized' }, { status: 401 }); } + const { success, reset } = await ratelimit.rahvaQuestion.limit(user); + + if (!success) { + const timeRemaining = Math.floor((reset - Date.now()) / 1000); + const message = `Võta veits rahulikumalt. API Proovi ${timeRemaining}s pärast uuesti.`; + + return json({ error: message }, { status: 429 }); + } + if (!content?.trim()) { return json({ error: 'Küsimustel on sisu vaja' }, { status: 400 }); } diff --git a/src/routes/vinge/pakubiiti/+page.server.ts b/src/routes/vinge/pakubiiti/+page.server.ts index fa14a77..ba8f98f 100644 --- a/src/routes/vinge/pakubiiti/+page.server.ts +++ b/src/routes/vinge/pakubiiti/+page.server.ts @@ -6,11 +6,12 @@ import { shuffleArray } from '$lib/utils'; import { albumState } from '$lib/server/pakubiiti/AlbumState.svelte'; import { playerState } from '$lib/server/pakubiiti/PlayerState.svelte'; +import { ratelimit } from '$lib/server/redis'; const count = 3; -export const load: PageServerLoad = async ({ fetch, locals }) => { - const { session } = locals; +export const load: PageServerLoad = async (event) => { + const { session } = event.locals; if (!session?.data?.userId) { await session.setData({ userId: nanoid() }); @@ -35,7 +36,45 @@ export const load: PageServerLoad = async ({ fetch, locals }) => { }; } - const albumData = fetch(`/api/pakubiiti/getAlbums/${count}`) + const useragent = event.request.headers.get('user-agent') || ''; + const ip = event.request.headers.get('cf-connecting-ip') || event.getClientAddress(); + + const { success: seshSuccess, reset: seshReset } = await ratelimit.pakubiiti.limit(user, { + userAgent: useragent, + ip: ip + }); + + const { success: ipSuccess, reset: ipReset } = await ratelimit.pakubiitiIP.limit(ip, { + userAgent: useragent, + ip: ip + }); + + if (!seshSuccess) { + const timeRemaining = Math.floor((seshReset - Date.now()) / 1000); + const message = `Sesh Proovi ${timeRemaining}s pärast uuesti.`; + + return { + stage: stage, + highscore: highscore, + playing: true, + error: { title: 'Võta veits rahulikumalt', message } + }; + } + + if (!ipSuccess) { + const timeRemaining = Math.floor((ipReset - Date.now()) / 1000); + const message = `IP Proovi ${timeRemaining}s pärast uuesti.`; + + return { + stage: stage, + highscore: highscore, + playing: true, + error: { title: 'Võta veits rahulikumalt', message } + }; + } + + const albumData = event + .fetch(`/api/pakubiiti/getAlbums/${count}`) .then((res) => { return res.json(); }) diff --git a/src/routes/vinge/pakubiiti/+page.svelte b/src/routes/vinge/pakubiiti/+page.svelte index ec2c122..d8fe541 100644 --- a/src/routes/vinge/pakubiiti/+page.svelte +++ b/src/routes/vinge/pakubiiti/+page.svelte @@ -64,29 +64,35 @@ {/if} {/snippet} - + - {#if data?.highscore && data?.stage && data.highscore === data.stage} + {#if data.error} + {data.error.title} + {:else if data?.highscore && data?.stage && data.highscore === data.stage} Uus parim tulemus! {:else} Seekord ei vedanud {/if} - {#if data.stage === 0} + {#if data.error} + {data.error.message} + {:else if data.stage === 0} Põrusid esimesel katsel. {:else} Vastasid õigesti {data.stage} korda. {/if} - -
- Uuesti -
-
+ {#if !data.error} + +
+ Uuesti +
+
+ {/if}
diff --git a/src/routes/vinge/rahvatarkus/+layout.svelte b/src/routes/vinge/rahvatarkus/+layout.svelte index dc9ade7..b053d5c 100644 --- a/src/routes/vinge/rahvatarkus/+layout.svelte +++ b/src/routes/vinge/rahvatarkus/+layout.svelte @@ -14,7 +14,7 @@ }); - + Vasta Küsi diff --git a/src/routes/vinge/rahvatarkus/+page.server.ts b/src/routes/vinge/rahvatarkus/+page.server.ts index d0dea83..41a3122 100644 --- a/src/routes/vinge/rahvatarkus/+page.server.ts +++ b/src/routes/vinge/rahvatarkus/+page.server.ts @@ -5,6 +5,7 @@ import { formSchema as answerSchema } from './answer-schema'; import { superValidate } from 'sveltekit-superforms'; import { zod } from 'sveltekit-superforms/adapters'; import { fail } from '@sveltejs/kit'; +import { ratelimit } from '$lib/server/redis'; const pageSize = 5; @@ -45,6 +46,47 @@ export const actions: Actions = { }); } + const useragent = event.request.headers.get('user-agent') || ''; + const ip = event.request.headers.get('cf-connecting-ip') || event.getClientAddress(); + + const { success: seshSuccess, reset: seshReset } = await ratelimit.rahvaAnswer.limit(user, { + userAgent: useragent, + ip: ip + }); + + const { success: ipSuccess, reset: ipReset } = await ratelimit.rahvaAnswerIP.limit(ip, { + userAgent: useragent, + ip: ip + }); + + if (!seshSuccess) { + const timeRemaining = Math.floor((seshReset - Date.now()) / 1000); + const message = `Võta veits rahulikumalt. Sesh Proovi ${timeRemaining}s pärast uuesti.`; + + if (form.errors.answer) { + form.errors.answer.push(message); + } else { + form.errors.answer = [message]; + } + return fail(429, { + form + }); + } + + if (!ipSuccess) { + const timeRemaining = Math.floor((ipReset - Date.now()) / 1000); + const message = `Võta veits rahulikumalt. IP Proovi ${timeRemaining}s pärast uuesti.`; + + if (form.errors.answer) { + form.errors.answer.push(message); + } else { + form.errors.answer = [message]; + } + return fail(429, { + form + }); + } + const response = await event .fetch('/api/rahvatarkus/answer', { method: 'POST', @@ -97,6 +139,47 @@ export const actions: Actions = { }); } + const useragent = event.request.headers.get('user-agent') || ''; + const ip = event.request.headers.get('cf-connecting-ip') || event.getClientAddress(); + + const { success: seshSuccess, reset: seshReset } = await ratelimit.rahvaQuestion.limit(user, { + userAgent: useragent, + ip: ip + }); + + const { success: ipSuccess, reset: ipReset } = await ratelimit.rahvaQuestionIP.limit(ip, { + userAgent: useragent, + ip: ip + }); + + if (!seshSuccess) { + const timeRemaining = Math.floor((seshReset - Date.now()) / 1000); + const message = `Võta veits rahulikumalt. Sesh Proovi ${timeRemaining}s pärast uuesti.`; + + if (form.errors.question) { + form.errors.question.push(message); + } else { + form.errors.question = [message]; + } + return fail(429, { + form + }); + } + + if (!ipSuccess) { + const timeRemaining = Math.floor((ipReset - Date.now()) / 1000); + const message = `Võta veits rahulikumalt. IP Proovi ${timeRemaining}s pärast uuesti.`; + + if (form.errors.question) { + form.errors.question.push(message); + } else { + form.errors.question = [message]; + } + return fail(429, { + form + }); + } + const response = await event .fetch('/api/rahvatarkus/question', { method: 'POST',