Skip to content

Commit bfcb507

Browse files
committed
Add rate limiting to generate description API
1 parent a264f2f commit bfcb507

3 files changed

Lines changed: 90 additions & 2 deletions

File tree

src/app/api/generateDescription/route.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import { type NextRequest, NextResponse } from 'next/server';
22
import generateDescription from '~/server/google-ai';
33
import { aiSchema } from './schema';
44
import { authorize } from '~/lib/authorize';
5+
import { enforceRateLimit } from '~/lib/rate-limit';
6+
7+
const RATE_LIMIT_KEY = 'generateDescription';
8+
const RATE_LIMIT_MAX_REQUESTS = 60;
9+
const RATE_LIMIT_WINDOW_MS = 60_000;
510

611
export async function POST(req: NextRequest) {
712
const authToken = req.headers.get('X-Auth-Token') || 'missing';
@@ -10,6 +15,27 @@ export async function POST(req: NextRequest) {
1015
return new NextResponse('Unauthorized', { status: 401 });
1116
}
1217

18+
const clientIdentifier =
19+
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || req.ip || 'unknown';
20+
const rateLimit = await enforceRateLimit(
21+
`${RATE_LIMIT_KEY}:${clientIdentifier}`,
22+
{ windowMs: RATE_LIMIT_WINDOW_MS, maxRequests: RATE_LIMIT_MAX_REQUESTS }
23+
);
24+
25+
if (!rateLimit.allowed) {
26+
const retryAfter = Math.max(0, Math.ceil((rateLimit.reset - Date.now()) / 1000));
27+
28+
return new NextResponse('Too Many Requests', {
29+
status: 429,
30+
headers: {
31+
'Retry-After': retryAfter.toString(),
32+
'X-RateLimit-Limit': rateLimit.limit.toString(),
33+
'X-RateLimit-Remaining': rateLimit.remaining.toString(),
34+
'X-RateLimit-Reset': rateLimit.reset.toString(),
35+
},
36+
});
37+
}
38+
1339
const data: unknown = await req.json();
1440
const parsedParams = aiSchema.safeParse(data);
1541

@@ -19,5 +45,11 @@ export async function POST(req: NextRequest) {
1945

2046
const description = await generateDescription(parsedParams.data);
2147

22-
return NextResponse.json({ description });
48+
const response = NextResponse.json({ description });
49+
50+
response.headers.set('X-RateLimit-Limit', rateLimit.limit.toString());
51+
response.headers.set('X-RateLimit-Remaining', rateLimit.remaining.toString());
52+
response.headers.set('X-RateLimit-Reset', rateLimit.reset.toString());
53+
54+
return response;
2355
}

src/lib/db.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export interface ClientTokenData {
3535
let app: FirebaseApp;
3636
let db: Firestore;
3737

38-
function getDb() {
38+
export function getDb() {
3939
if (!db) {
4040
const { FIRE_API_KEY, FIRE_DOMAIN, FIRE_PROJECT_ID } = env;
4141

src/lib/rate-limit.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { doc, runTransaction } from 'firebase/firestore';
2+
import { getDb } from './db';
3+
4+
interface RateLimitOptions {
5+
windowMs?: number;
6+
maxRequests?: number;
7+
}
8+
9+
interface RateLimitDocument {
10+
count: number;
11+
windowStart: number;
12+
}
13+
14+
export interface RateLimitState {
15+
allowed: boolean;
16+
remaining: number;
17+
reset: number;
18+
limit: number;
19+
}
20+
21+
const DEFAULT_WINDOW_MS = 60_000;
22+
const DEFAULT_MAX_REQUESTS = 30;
23+
24+
export async function enforceRateLimit(
25+
key: string,
26+
{ windowMs = DEFAULT_WINDOW_MS, maxRequests = DEFAULT_MAX_REQUESTS }: RateLimitOptions = {}
27+
): Promise<RateLimitState> {
28+
const db = getDb();
29+
const ref = doc(db, 'rateLimits', key);
30+
const now = Date.now();
31+
32+
let state: RateLimitState = {
33+
allowed: true,
34+
remaining: maxRequests,
35+
reset: now + windowMs,
36+
limit: maxRequests,
37+
};
38+
39+
await runTransaction(db, async (transaction) => {
40+
const snapshot = await transaction.get(ref);
41+
const data = snapshot.data() as RateLimitDocument | undefined;
42+
const withinWindow = data && now - data.windowStart < windowMs;
43+
44+
const windowStart = withinWindow ? data.windowStart : now;
45+
const newCount = (withinWindow ? data.count : 0) + 1;
46+
const remaining = Math.max(0, maxRequests - newCount);
47+
const reset = windowStart + windowMs;
48+
const allowed = newCount <= maxRequests;
49+
50+
transaction.set(ref, { count: newCount, windowStart });
51+
52+
state = { allowed, remaining, reset, limit: maxRequests };
53+
});
54+
55+
return state;
56+
}

0 commit comments

Comments
 (0)