Files
skillhub/apps/web/lib/rate-limit.ts
airano 97b427831a 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>
2026-02-12 06:42:07 +03:30

264 lines
6.7 KiB
TypeScript

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);
}