Add a rate limiter

This commit is contained in:
Mihkel Martin Kasterpalu 2025-02-11 23:33:50 +02:00
parent 0526d0029b
commit 94b659b1f0
10 changed files with 274 additions and 12 deletions

View file

@ -1,4 +1,13 @@
# Spotify API
CLIENT_ID=<spotifyAPIID>
CLIENT_SECRET=<spotifyAPISecret>
# Session token salt
SESH_SECRET=<longRandomString>
# SQLite DB location
DATABASE_URL=local.db
# Rate limit Redis instance on upstash
UPSTASH_REDIS_URL=""
UPSTASH_REDIS_TOKEN=""

View file

@ -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",

36
pnpm-lock.yaml generated
View file

@ -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:

58
src/lib/server/redis.ts Normal file
View file

@ -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')
})
};

View file

@ -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

View file

@ -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 });
}

View file

@ -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();
})

View file

@ -64,29 +64,35 @@
{/if}
{/snippet}
<AlertDialog.Root open={data.playing === false}>
<AlertDialog.Root open={data.playing === false || data.error}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>
{#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}
</AlertDialog.Title>
<AlertDialog.Description>
{#if data.stage === 0}
{#if data.error}
{data.error.message}
{:else if data.stage === 0}
Põrusid esimesel katsel.
{:else}
Vastasid õigesti <strong>{data.stage} korda.</strong>
{/if}
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<form action="?/restart" method="POST" use:enhance>
<AlertDialog.Action type="submit">Uuesti</AlertDialog.Action>
</form>
</AlertDialog.Footer>
{#if !data.error}
<AlertDialog.Footer>
<form action="?/restart" method="POST" use:enhance>
<AlertDialog.Action type="submit">Uuesti</AlertDialog.Action>
</form>
</AlertDialog.Footer>
{/if}
</AlertDialog.Content>
</AlertDialog.Root>

View file

@ -14,7 +14,7 @@
});
</script>
<Tabs.Root value={firstTab} class="flex w-full max-w-md flex-col items-center gap-1">
<Tabs.Root bind:value={firstTab} class="flex w-full max-w-md flex-col items-center gap-1">
<Tabs.List class="grid w-full grid-cols-2">
<Tabs.Trigger value="answer">Vasta</Tabs.Trigger>
<Tabs.Trigger value="question">Küsi</Tabs.Trigger>

View file

@ -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',