feat: introduce API rate limiting for API routes and cap package query limits.

This commit is contained in:
Yusuf İpek
2025-11-20 15:55:43 +03:00
parent 48e2d2f05d
commit af1ae5f1f8
7 changed files with 130 additions and 17 deletions
+1
View File
@@ -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",
+9
View File
@@ -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([email protected])
@@ -1655,6 +1658,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
[email protected]:
resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==}
engines: {node: 20 || >=22}
[email protected]:
resolution: {integrity: sha512-V7o0/VECSGbLHn3/1O67FUgBwWB+hmzshrgDVRJQhMh8uj5D3HBuIvhuAmQTtlupILSplwIZg5FTc4tTKMA2SA==}
peerDependencies:
@@ -4162,6 +4169,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]([email protected]):
dependencies:
react: 18.3.1
+49
View File
@@ -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()
+7 -7
View File
@@ -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,
+36
View File
@@ -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<void>((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()
}
}),
}
}
+21 -4
View File
@@ -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).*)',
],
}
+7 -6
View File
@@ -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