mirror of
https://github.com/yusufipk/RepoHub.git
synced 2026-09-11 10:36:07 +00:00
feat: introduce API rate limiting for API routes and cap package query limits.
This commit is contained in:
@@ -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",
|
||||
|
||||
Generated
+9
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -14,7 +14,7 @@ export async function GET(request: NextRequest) {
|
||||
repository: searchParams.get('repository') as 'official' | 'third-party' | 'aur' | undefined,
|
||||
search: searchParams.get('search') || undefined,
|
||||
limit: searchParams.get('limit') ?
|
||||
parseInt(searchParams.get('limit')!) : undefined,
|
||||
Math.min(parseInt(searchParams.get('limit')!), 100) : undefined,
|
||||
offset: searchParams.get('offset') ?
|
||||
parseInt(searchParams.get('offset')!) : undefined,
|
||||
sort_by: searchParams.get('sort_by') as
|
||||
|
||||
@@ -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
@@ -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).*)',
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -214,8 +215,8 @@ export class PackageService {
|
||||
) 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]
|
||||
license_id, type, repository, homepage_url, download_url,
|
||||
popularity_score]
|
||||
)
|
||||
|
||||
const createdPackage = await this.getById(result.rows[0].id)
|
||||
|
||||
Reference in New Issue
Block a user