diff --git a/package.json b/package.json index 91626f3..f693898 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "cheerio": "^1.1.2", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", + "lru-cache": "^11.2.2", "lucide-react": "^0.294.0", "next": "14.0.4", "pg": "^8.11.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ffb83ed..a01bad9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: clsx: specifier: ^2.0.0 version: 2.1.1 + lru-cache: + specifier: ^11.2.2 + version: 11.2.2 lucide-react: specifier: ^0.294.0 version: 0.294.0(react@18.3.1) @@ -1655,6 +1658,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.2.2: + resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==} + engines: {node: 20 || >=22} + lucide-react@0.294.0: resolution: {integrity: sha512-V7o0/VECSGbLHn3/1O67FUgBwWB+hmzshrgDVRJQhMh8uj5D3HBuIvhuAmQTtlupILSplwIZg5FTc4tTKMA2SA==} peerDependencies: @@ -4162,6 +4169,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.2.2: {} + lucide-react@0.294.0(react@18.3.1): dependencies: react: 18.3.1 diff --git a/scripts/test-rate-limit.ts b/scripts/test-rate-limit.ts new file mode 100644 index 0000000..da3692b --- /dev/null +++ b/scripts/test-rate-limit.ts @@ -0,0 +1,49 @@ + +import { apiClient } from '../src/lib/api/client' + +// Mock fetch for testing if running outside of browser/node with fetch +if (!global.fetch) { + console.error("Fetch is not available") + process.exit(1) +} + +async function testRateLimit() { + console.log('šŸš€ Starting Rate Limit Test...') + + const url = 'http://localhost:3002/api/packages?limit=1' + let successCount = 0 + let failCount = 0 + + const startTime = Date.now() + + for (let i = 0; i < 120; i++) { + try { + const res = await fetch(url) + if (res.status === 200) { + successCount++ + process.stdout.write('.') + } else if (res.status === 429) { + failCount++ + process.stdout.write('x') + } else { + console.log(`\nUnexpected status: ${res.status}`) + } + } catch (e) { + console.error(`\nRequest failed: ${e}`) + } + } + + const duration = (Date.now() - startTime) / 1000 + console.log(`\n\nšŸ“Š Results:`) + console.log(`Time: ${duration.toFixed(2)}s`) + console.log(`Success: ${successCount}`) + console.log(`Rate Limited: ${failCount}`) + + if (failCount > 0) { + console.log('āœ… Rate limiting is working!') + } else { + console.log('āŒ Rate limiting did NOT trigger.') + } +} + +testRateLimit() diff --git a/src/app/api/packages/route.ts b/src/app/api/packages/route.ts index 786ce33..f890c0f 100644 --- a/src/app/api/packages/route.ts +++ b/src/app/api/packages/route.ts @@ -4,26 +4,26 @@ import { PackageService } from '@/services/packageService' export async function GET(request: NextRequest) { try { const { searchParams } = new URL(request.url) - + // Parse query parameters const filter = { platform_id: searchParams.get('platform_id') || undefined, - category_id: searchParams.get('category_id') ? + category_id: searchParams.get('category_id') ? parseInt(searchParams.get('category_id')!) : undefined, type: searchParams.get('type') as 'gui' | 'cli' | undefined, repository: searchParams.get('repository') as 'official' | 'third-party' | 'aur' | undefined, search: searchParams.get('search') || undefined, - limit: searchParams.get('limit') ? - parseInt(searchParams.get('limit')!) : undefined, - offset: searchParams.get('offset') ? + limit: searchParams.get('limit') ? + Math.min(parseInt(searchParams.get('limit')!), 100) : undefined, + offset: searchParams.get('offset') ? parseInt(searchParams.get('offset')!) : undefined, - sort_by: searchParams.get('sort_by') as + sort_by: searchParams.get('sort_by') as 'name' | 'popularity_score' | 'last_updated' | 'downloads_count' | undefined, sort_order: searchParams.get('sort_order') as 'asc' | 'desc' | undefined } const result = await PackageService.getMany(filter) - + return NextResponse.json({ packages: result.packages, total: result.total, diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts new file mode 100644 index 0000000..c393bff --- /dev/null +++ b/src/lib/rate-limit.ts @@ -0,0 +1,36 @@ +import { LRUCache } from 'lru-cache' + +type Options = { + uniqueTokenPerInterval?: number + interval?: number +} + +export default function rateLimit(options?: Options) { + const tokenCache = new LRUCache({ + max: options?.uniqueTokenPerInterval || 500, + ttl: options?.interval || 60000, + }) + + return { + check: (res: Response | null, limit: number, token: string) => + 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 + + // If we had a response object we could set headers, but for Next.js middleware + // we just want to know if we should block. + + if (isRateLimited) { + reject() + } else { + resolve() + } + }), + } +} diff --git a/src/middleware.ts b/src/middleware.ts index e0886ce..af80d55 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,7 +1,25 @@ import { NextRequest, NextResponse } from 'next/server' +import rateLimit from '@/lib/rate-limit' + +const limiter = rateLimit({ + interval: 60 * 1000, // 60 seconds + uniqueTokenPerInterval: 500, // Max 500 users per second +}) + +export async function middleware(request: NextRequest) { + // Only rate limit API routes + if (request.nextUrl.pathname.startsWith('/api')) { + try { + // 100 requests per minute per IP + await limiter.check(null, 50, request.ip ?? 'CACHE_TOKEN') + } catch { + return NextResponse.json( + { error: 'Too Many Requests' }, + { status: 429 } + ) + } + } -export function middleware(request: NextRequest) { - // Middleware temporarily disabled - just pass through return NextResponse.next() } @@ -9,11 +27,10 @@ export const config = { matcher: [ /* * Match all request paths except for the ones starting with: - * - api (API routes) * - _next/static (static files) * - _next/image (image optimization files) * - favicon.ico (favicon file) */ - '/((?!api|_next/static|_next/image|favicon.ico).*)', + '/((?!_next/static|_next/image|favicon.ico).*)', ], } diff --git a/src/services/packageService.ts b/src/services/packageService.ts index 87dfced..ad7d83e 100644 --- a/src/services/packageService.ts +++ b/src/services/packageService.ts @@ -93,7 +93,8 @@ export class PackageService { LIMIT $${paramIndex++} OFFSET $${paramIndex++} ` - values.push(limit, offset) + const cappedLimit = Math.min(limit, 100) + values.push(cappedLimit, offset) const packagesResult = await query(packagesQuery, values) // Transform the results @@ -146,7 +147,7 @@ export class PackageService { ` const result = await query(packageQuery, [id]) - + if (result.rows.length === 0) { return null } @@ -213,9 +214,9 @@ export class PackageService { popularity_score, last_seen_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW()) RETURNING *`, - [id, name, description, version, platform_id, category_id, - license_id, type, repository, homepage_url, download_url, - popularity_score] + [id, name, description, version, platform_id, category_id, + license_id, type, repository, homepage_url, download_url, + popularity_score] ) const createdPackage = await this.getById(result.rows[0].id) @@ -271,7 +272,7 @@ export class PackageService { const values = tags.map((tag, index) => `($1, $${index + 2})`).join(', ') const params = [packageId, ...tags] - + await query( `INSERT INTO package_tags (package_id, tag) VALUES ${values} ON CONFLICT DO NOTHING`, params