Initial release v1.0.0
Open-source marketplace for AI Agent skills. Features: - Next.js 15 web app with i18n (en/fa) - CLI tool for skill installation (npx skillhub) - GitHub crawler/indexer with multi-strategy discovery - Security scanning for all indexed skills - Self-hostable with Docker Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
263
apps/web/lib/rate-limit.ts
Normal file
263
apps/web/lib/rate-limit.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import Redis from 'ioredis';
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
// Redis client singleton (shared with cache.ts)
|
||||
let redis: Redis | null = null;
|
||||
|
||||
function getRedis(): Redis | null {
|
||||
if (redis) return redis;
|
||||
|
||||
const redisUrl = process.env.REDIS_URL;
|
||||
if (!redisUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
redis = new Redis(redisUrl, {
|
||||
maxRetriesPerRequest: 3,
|
||||
retryStrategy: (times) => {
|
||||
if (times > 3) return null;
|
||||
return Math.min(times * 100, 3000);
|
||||
},
|
||||
lazyConnect: true,
|
||||
});
|
||||
|
||||
redis.on('error', (err) => {
|
||||
console.error('[RateLimit] Redis error:', err.message);
|
||||
});
|
||||
|
||||
return redis;
|
||||
} catch (error) {
|
||||
console.error('[RateLimit] Failed to initialize Redis:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limit configuration
|
||||
* Liberal limits as requested by user
|
||||
*/
|
||||
interface RateLimitConfig {
|
||||
windowMs: number; // Time window in milliseconds
|
||||
maxRequests: number; // Max requests per window
|
||||
}
|
||||
|
||||
const RATE_LIMITS: Record<string, RateLimitConfig> = {
|
||||
// Liberal limits for general API access
|
||||
anonymous: {
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
maxRequests: 120, // 120 req/min
|
||||
},
|
||||
authenticated: {
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
maxRequests: 600, // 600 req/min
|
||||
},
|
||||
// More restrictive for search (expensive operation)
|
||||
search: {
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
maxRequests: 60, // 60 req/min
|
||||
},
|
||||
};
|
||||
|
||||
export interface RateLimitResult {
|
||||
allowed: boolean;
|
||||
remaining: number;
|
||||
resetAt: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check rate limit using sliding window algorithm
|
||||
*/
|
||||
export async function checkRateLimit(
|
||||
identifier: string,
|
||||
type: keyof typeof RATE_LIMITS = 'anonymous'
|
||||
): Promise<RateLimitResult> {
|
||||
const client = getRedis();
|
||||
const config = RATE_LIMITS[type];
|
||||
|
||||
// If Redis unavailable, allow request (fail open)
|
||||
if (!client) {
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: config.maxRequests,
|
||||
resetAt: 0,
|
||||
limit: config.maxRequests,
|
||||
};
|
||||
}
|
||||
|
||||
const key = `ratelimit:${type}:${identifier}`;
|
||||
const now = Date.now();
|
||||
const windowStart = now - config.windowMs;
|
||||
|
||||
try {
|
||||
// Use pipeline for atomic operations
|
||||
const pipeline = client.pipeline();
|
||||
|
||||
// Remove old entries outside the window
|
||||
pipeline.zremrangebyscore(key, 0, windowStart);
|
||||
|
||||
// Count remaining entries
|
||||
pipeline.zcard(key);
|
||||
|
||||
// Get the oldest entry timestamp (for reset time calculation)
|
||||
pipeline.zrange(key, 0, 0, 'WITHSCORES');
|
||||
|
||||
const results = await pipeline.exec();
|
||||
|
||||
if (!results) {
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: config.maxRequests,
|
||||
resetAt: 0,
|
||||
limit: config.maxRequests,
|
||||
};
|
||||
}
|
||||
|
||||
const count = results[1]?.[1] as number || 0;
|
||||
|
||||
// Check if over limit
|
||||
if (count >= config.maxRequests) {
|
||||
const oldestEntry = results[2]?.[1] as string[] || [];
|
||||
const oldestTimestamp = oldestEntry[1] ? parseInt(oldestEntry[1]) : now;
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: oldestTimestamp + config.windowMs,
|
||||
limit: config.maxRequests,
|
||||
};
|
||||
}
|
||||
|
||||
// Add current request to the window
|
||||
await client.zadd(key, now, `${now}:${Math.random()}`);
|
||||
await client.expire(key, Math.ceil(config.windowMs / 1000) + 1);
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: config.maxRequests - count - 1,
|
||||
resetAt: now + config.windowMs,
|
||||
limit: config.maxRequests,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[RateLimit] Error checking rate limit:', error);
|
||||
// Fail open on errors
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: config.maxRequests,
|
||||
resetAt: 0,
|
||||
limit: config.maxRequests,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client identifier from request
|
||||
* Uses X-Forwarded-For header or falls back to a default
|
||||
*/
|
||||
export function getClientIdentifier(request: NextRequest): string {
|
||||
// Check for forwarded IP (behind proxy/load balancer)
|
||||
const forwarded = request.headers.get('x-forwarded-for');
|
||||
if (forwarded) {
|
||||
return forwarded.split(',')[0].trim();
|
||||
}
|
||||
|
||||
// Check for real IP header
|
||||
const realIp = request.headers.get('x-real-ip');
|
||||
if (realIp) {
|
||||
return realIp;
|
||||
}
|
||||
|
||||
// Fall back to a hash of other identifying headers
|
||||
const userAgent = request.headers.get('user-agent') || 'unknown';
|
||||
return `ua:${hashString(userAgent)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple hash function for fallback identification
|
||||
*/
|
||||
function hashString(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
return Math.abs(hash).toString(36);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rate limit response headers
|
||||
*/
|
||||
export function createRateLimitHeaders(result: RateLimitResult): Record<string, string> {
|
||||
return {
|
||||
'X-RateLimit-Limit': result.limit.toString(),
|
||||
'X-RateLimit-Remaining': Math.max(0, result.remaining).toString(),
|
||||
'X-RateLimit-Reset': Math.ceil(result.resetAt / 1000).toString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create 429 Too Many Requests response
|
||||
* @param result - Rate limit result
|
||||
* @param type - Rate limit type (for message customization)
|
||||
* @param isAuthenticated - Whether user is logged in
|
||||
*/
|
||||
export function createRateLimitResponse(
|
||||
result: RateLimitResult,
|
||||
type: keyof typeof RATE_LIMITS = 'anonymous',
|
||||
isAuthenticated: boolean = false
|
||||
): NextResponse {
|
||||
const retryAfter = Math.ceil((result.resetAt - Date.now()) / 1000);
|
||||
|
||||
// Customize message based on context
|
||||
const message = `Rate limit exceeded (${result.limit} requests/minute). Please try again in ${retryAfter} seconds.`;
|
||||
|
||||
// Add helpful tips
|
||||
let tip: string | undefined;
|
||||
if (type === 'search') {
|
||||
tip = 'Search operations are limited. Consider browsing categories instead.';
|
||||
} else if (!isAuthenticated) {
|
||||
tip = 'Sign in to increase your rate limit to 600 requests/minute.';
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Too Many Requests',
|
||||
message,
|
||||
tip,
|
||||
retryAfter,
|
||||
limit: result.limit,
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
...createRateLimitHeaders(result),
|
||||
'Retry-After': Math.max(1, retryAfter).toString(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limit middleware wrapper for API routes
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* export async function GET(request: NextRequest) {
|
||||
* const rateLimitResult = await withRateLimit(request, 'anonymous');
|
||||
* if (!rateLimitResult.allowed) {
|
||||
* return createRateLimitResponse(rateLimitResult);
|
||||
* }
|
||||
* // ... handle request
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export async function withRateLimit(
|
||||
request: NextRequest,
|
||||
type: keyof typeof RATE_LIMITS = 'anonymous'
|
||||
): Promise<RateLimitResult> {
|
||||
const identifier = getClientIdentifier(request);
|
||||
return checkRateLimit(identifier, type);
|
||||
}
|
||||
Reference in New Issue
Block a user