diff --git a/src/lib/server/rate-limit.ts b/src/lib/server/rate-limit.ts new file mode 100644 index 0000000..3eb7ed0 --- /dev/null +++ b/src/lib/server/rate-limit.ts @@ -0,0 +1,33 @@ +export class RateLimiter { + private windows = new Map(); + + constructor( + private maxRequests: number, + private windowMs: number + ) {} + + check(key: string): boolean { + const now = Date.now(); + const timestamps = this.windows.get(key) ?? []; + + const valid = timestamps.filter((t) => now - t < this.windowMs); + + if (valid.length >= this.maxRequests) { + this.windows.set(key, valid); + return false; + } + + valid.push(now); + this.windows.set(key, valid); + return true; + } + + reset(key: string): void { + this.windows.delete(key); + } +} + +export const loginLimiter = new RateLimiter(5, 15 * 60 * 1000); +export const registrationLimiter = new RateLimiter(3, 60 * 60 * 1000); +export const apiLimiter = new RateLimiter(60, 60 * 1000); +export const nameGeneratorLimiter = new RateLimiter(10, 5 * 60 * 1000); diff --git a/tests/unit/rate-limit.test.ts b/tests/unit/rate-limit.test.ts new file mode 100644 index 0000000..6ea59e2 --- /dev/null +++ b/tests/unit/rate-limit.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { RateLimiter } from '$lib/server/rate-limit'; + +describe('rate-limit', () => { + it('allows requests under the limit', () => { + const limiter = new RateLimiter(3, 60000); + expect(limiter.check('ip1')).toBe(true); + expect(limiter.check('ip1')).toBe(true); + expect(limiter.check('ip1')).toBe(true); + }); + + it('blocks requests over the limit', () => { + const limiter = new RateLimiter(2, 60000); + expect(limiter.check('ip1')).toBe(true); + expect(limiter.check('ip1')).toBe(true); + expect(limiter.check('ip1')).toBe(false); + }); + + it('tracks different keys independently', () => { + const limiter = new RateLimiter(1, 60000); + expect(limiter.check('ip1')).toBe(true); + expect(limiter.check('ip2')).toBe(true); + expect(limiter.check('ip1')).toBe(false); + }); +});