Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@
# MongoDB Atlas (production example)
# MONGODB_URI=mongodb+srv://<username>:<password>@cluster.mongodb.net/iiitl

# Upstash Redis
# UPSTASH_REDIS_REST_URL=
# UPSTASH_REDIS_REST_TOKEN=

# Mailgun Configuration
MAILGUN_API_KEY=""
MAILGUN_DOMAIN=""
# MAILGUN_URL="https://api.eu.mailgun.net" # Uncomment if using an EU region domain
EMAIL_FROM="no-reply@yourdomain.com"
EMAIL_FROM="no-reply@yourdomain.com"
Comment thread
MrImmortal09 marked this conversation as resolved.
83 changes: 83 additions & 0 deletions lib/ratelimit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { Ratelimit } from '@upstash/ratelimit';
Comment thread
MrImmortal09 marked this conversation as resolved.
import { Redis } from '@upstash/redis';

const redisClient = (process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN)
? new Redis({
url: process.env.UPSTASH_REDIS_REST_URL,
token: process.env.UPSTASH_REDIS_REST_TOKEN,
})
: null;

type Unit = 'ms' | 's' | 'm' | 'h' | 'd';
type Duration = `${number} ${Unit}` | `${number}${Unit}`;

interface RateLimitConfig {
maxRequests: number;
window: Duration;
}

const ratelimitCache = new Map<string, Ratelimit>();

export async function limit(key: string, config: RateLimitConfig) {
if (!redisClient) {
console.warn("Rate limiting bypassed: UPSTASH_REDIS credentials are missing.");
return { success: true, limit: config.maxRequests, remaining: config.maxRequests, reset: 0, retryAfter: 0 };
}
Comment thread
MrImmortal09 marked this conversation as resolved.

// Retrieve existing rate limiter from cache, or create and cache a new one for this config
const cacheKey = `${config.maxRequests}-${config.window}`;
let ratelimit = ratelimitCache.get(cacheKey);

if (!ratelimit) {
ratelimit = new Ratelimit({
redis: redisClient,
limiter: Ratelimit.slidingWindow(config.maxRequests, config.window),
analytics: true,
});
ratelimitCache.set(cacheKey, ratelimit);
}

try {
const result = await ratelimit.limit(key);

// Calculate standard Retry-After in seconds
const retryAfterSeconds = Math.max(1, Math.ceil((result.reset - Date.now()) / 1000));

if (!result.success) {
const breachKey = `breach_count:${key}`;

const pipeline = redisClient.pipeline();
pipeline.incr(breachKey);
pipeline.expire(breachKey, 3600);

const results = await pipeline.exec();
const breachCount = results[0] as number;

if (breachCount === 3) {
console.log({
severity: 'WARNING',
event: 'CONSISTENT_RATE_LIMIT_BREACH',
target: key,
message: `Key breached rate limits ${breachCount} times within the hour.`,
});
}
Comment thread
MrImmortal09 marked this conversation as resolved.
}

return {
success: result.success,
limit: result.limit,
remaining: result.remaining,
reset: result.reset,
retryAfter: retryAfterSeconds,
};
} catch (error) {
console.error("Rate limiting error:", error);
return {
success: true,
limit: config.maxRequests,
remaining: config.maxRequests,
reset: 0,
retryAfter: 0,
};
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Comment thread
MrImmortal09 marked this conversation as resolved.
45 changes: 45 additions & 0 deletions lib/with-ratelimit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import { limit } from "./ratelimit";

/**
* Wraps a Next.js API route handler to apply rate limiting.
*
* @example
* export const POST = withRateLimit(async (req: NextRequest) => {
* // Create logic
* return Response.json({ success: true });
* });
*
* export const PUT = withRateLimit(async (req: NextRequest) => {
* // Update logic
* return Response.json({ success: true });
* });
*
* export const DELETE = withRateLimit(async (req: NextRequest) => {
* // Delete logic
* return Response.json({ success: true });
* });
*/
export function withRateLimit<T extends unknown[]>(
handler: (req: NextRequest, ...args: T) => Promise<Response> | Response
) {
return async (req: NextRequest, ...args: T) => {
const ip = req.headers.get("x-forwarded-for") || req.headers.get("x-real-ip") || "127.0.0.1";
Comment thread
MrImmortal09 marked this conversation as resolved.

const identifier = `${req.nextUrl.pathname}:${ip}`;

const { success, retryAfter } = await limit(identifier, {
maxRequests: 5,
window: "1 m",
});

if (!success) {
return NextResponse.json(
{ error: "Too many requests. Please try again later." },
{ status: 429, headers: { "Retry-After": retryAfter.toString() } }
);
}

return handler(req, ...args);
};
}
Comment thread
MrImmortal09 marked this conversation as resolved.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
"db:seed": "node scripts/seed.js"
},
"dependencies": {
"@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.37.0",
"dotenv": "^17.4.1",
"form-data": "^4.0.5",
"mailgun.js": "^12.7.1",
Expand Down
36 changes: 36 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.