Add a rate limiter
This commit is contained in:
parent
0526d0029b
commit
94b659b1f0
10 changed files with 274 additions and 12 deletions
|
@ -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=""
|
||||
|
|
|
@ -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
36
pnpm-lock.yaml
generated
|
@ -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
58
src/lib/server/redis.ts
Normal 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')
|
||||
})
|
||||
};
|
|
@ -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
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
})
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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',
|
||||
|
|
Loading…
Add table
Reference in a new issue