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",
|
"cheerio": "^1.1.2",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
|
"lru-cache": "^11.2.2",
|
||||||
"lucide-react": "^0.294.0",
|
"lucide-react": "^0.294.0",
|
||||||
"next": "14.0.4",
|
"next": "14.0.4",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
|
|||||||
Generated
+9
@@ -35,6 +35,9 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
|
lru-cache:
|
||||||
|
specifier: ^11.2.2
|
||||||
|
version: 11.2.2
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.294.0
|
specifier: ^0.294.0
|
||||||
version: 0.294.0([email protected])
|
version: 0.294.0([email protected])
|
||||||
@@ -1655,6 +1658,10 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==}
|
||||||
|
engines: {node: 20 || >=22}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-V7o0/VECSGbLHn3/1O67FUgBwWB+hmzshrgDVRJQhMh8uj5D3HBuIvhuAmQTtlupILSplwIZg5FTc4tTKMA2SA==}
|
resolution: {integrity: sha512-V7o0/VECSGbLHn3/1O67FUgBwWB+hmzshrgDVRJQhMh8uj5D3HBuIvhuAmQTtlupILSplwIZg5FTc4tTKMA2SA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -4162,6 +4169,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]([email protected]):
|
[email protected]([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 18.3.1
|
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,
|
repository: searchParams.get('repository') as 'official' | 'third-party' | 'aur' | undefined,
|
||||||
search: searchParams.get('search') || undefined,
|
search: searchParams.get('search') || undefined,
|
||||||
limit: searchParams.get('limit') ?
|
limit: searchParams.get('limit') ?
|
||||||
parseInt(searchParams.get('limit')!) : undefined,
|
Math.min(parseInt(searchParams.get('limit')!), 100) : undefined,
|
||||||
offset: searchParams.get('offset') ?
|
offset: searchParams.get('offset') ?
|
||||||
parseInt(searchParams.get('offset')!) : undefined,
|
parseInt(searchParams.get('offset')!) : undefined,
|
||||||
sort_by: searchParams.get('sort_by') as
|
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 { 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()
|
return NextResponse.next()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9,11 +27,10 @@ export const config = {
|
|||||||
matcher: [
|
matcher: [
|
||||||
/*
|
/*
|
||||||
* Match all request paths except for the ones starting with:
|
* Match all request paths except for the ones starting with:
|
||||||
* - api (API routes)
|
|
||||||
* - _next/static (static files)
|
* - _next/static (static files)
|
||||||
* - _next/image (image optimization files)
|
* - _next/image (image optimization files)
|
||||||
* - favicon.ico (favicon file)
|
* - 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++}
|
LIMIT $${paramIndex++} OFFSET $${paramIndex++}
|
||||||
`
|
`
|
||||||
|
|
||||||
values.push(limit, offset)
|
const cappedLimit = Math.min(limit, 100)
|
||||||
|
values.push(cappedLimit, offset)
|
||||||
const packagesResult = await query(packagesQuery, values)
|
const packagesResult = await query(packagesQuery, values)
|
||||||
|
|
||||||
// Transform the results
|
// Transform the results
|
||||||
@@ -214,8 +215,8 @@ export class PackageService {
|
|||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW())
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW())
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[id, name, description, version, platform_id, category_id,
|
[id, name, description, version, platform_id, category_id,
|
||||||
license_id, type, repository, homepage_url, download_url,
|
license_id, type, repository, homepage_url, download_url,
|
||||||
popularity_score]
|
popularity_score]
|
||||||
)
|
)
|
||||||
|
|
||||||
const createdPackage = await this.getById(result.rows[0].id)
|
const createdPackage = await this.getById(result.rows[0].id)
|
||||||
|
|||||||
Reference in New Issue
Block a user