diff --git a/src/app/(public)/links/route.ts b/src/app/(public)/links/route.ts index 3396a1a..6ab95b0 100644 --- a/src/app/(public)/links/route.ts +++ b/src/app/(public)/links/route.ts @@ -5,6 +5,7 @@ import { checkIfUrlIsValid } from '@/lib/helper'; import { checkSlugExists, generateRandomSlug } from '@/lib/helper-server'; import { NEXT_PUBLIC_URL } from '@/lib/env'; import { linkSchema } from '@/app/schema'; +import type { Link } from '@/app/schema'; import type { APIResponse } from '@/lib/types/api'; import type { LinkMeta } from '@/lib/types/meta'; @@ -12,7 +13,9 @@ export async function POST( request: Request ): Promise>> { try { - const body = (await request.json()) as unknown; + const body = (await request.json().catch(() => null)) as Link | null; + + if (!body) throw new Error('Invalid request body'); const { url, slug } = linkSchema.parse(body); diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts new file mode 100644 index 0000000..ab23eab --- /dev/null +++ b/src/lib/rate-limit.ts @@ -0,0 +1,49 @@ +import { LRUCache } from 'lru-cache'; + +type Options = { + interval?: number; + uniqueTokenPerInterval?: number; +}; + +/** + * Rate limit helper. + * + * @param {Options} [options] Options + * @param {number} [options.interval=60000] Interval in milliseconds + * @param {number} [options.uniqueTokenPerInterval=500] Max unique tokens per interval + */ +export function rateLimit({ + interval = 60 * 1000, + uniqueTokenPerInterval = 500 +}: Options = {}): { + check: (options: { + limit: number; + token: string; + headers: Headers; + }) => Promise; +} { + const tokenCache = new LRUCache({ + ttl: interval, + max: uniqueTokenPerInterval + }); + + return { + check: ({ headers, limit, token }) => + new Promise((resolve, reject) => { + const tokenCount = (tokenCache.get(token) as number[]) || [0]; + + if (tokenCount[0] === 0) tokenCache.set(token, tokenCount); + + tokenCount[0] += 1; + + const currentUsage = tokenCount[0]; + const isRateLimited = currentUsage >= limit; + const limitRemaining = isRateLimited ? 0 : limit - currentUsage; + + headers.set('X-RateLimit-Limit', limit.toString()); + headers.set('X-RateLimit-Remaining', limitRemaining.toString()); + + return isRateLimited ? reject() : resolve(); + }) + }; +} diff --git a/src/lib/types/api.ts b/src/lib/types/api.ts index 25ba623..d430ef6 100644 --- a/src/lib/types/api.ts +++ b/src/lib/types/api.ts @@ -1,4 +1,4 @@ export type APIResponse = { - data?: T; message: string; + data?: T; }; diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..97adf75 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,34 @@ +import { NextResponse } from 'next/server'; +import { rateLimit } from './lib/rate-limit'; + +const limiter = rateLimit({ + interval: 60 * 1000, // 1 minute + uniqueTokenPerInterval: 500 // Max 500 users per minute +}); + +export async function middleware(request: Request): Promise { + const headers = new Headers(request.headers); + + try { + await limiter.check({ + limit: 10, + token: 'CACHE_TOKEN', + headers: headers + }); + } catch { + return NextResponse.json( + { message: 'Too many requests' }, + { status: 429, headers: headers } + ); + } + + return NextResponse.next({ headers }); +} + +type Config = { + matcher: string; +}; + +export const config: Config = { + matcher: '/links' +};