Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
149 changes: 149 additions & 0 deletions app/api/_utils/rateLimit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
import { NextRequest, NextResponse } from 'next/server';

// ---------------------------------------------------------------------------
// Upstash Redis client
// Requires env vars: UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN
// ---------------------------------------------------------------------------
function getRedis(): Redis | null {
const url = process.env.UPSTASH_REDIS_REST_URL;
const token = process.env.UPSTASH_REDIS_REST_TOKEN;
if (!url || !token) return null;
return new Redis({ url, token });
}

// ---------------------------------------------------------------------------
// Rate limiters (created lazily; only when Redis credentials are present)
// ---------------------------------------------------------------------------
let _authenticatedLimiter: Ratelimit | null = null;
let _publicLimiter: Ratelimit | null = null;
let _aiLimiter: Ratelimit | null = null;

function getAuthenticatedLimiter(): Ratelimit | null {
if (_authenticatedLimiter) return _authenticatedLimiter;
const redis = getRedis();
if (!redis) return null;
_authenticatedLimiter = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(60, '1 m'), // 60 req/min for authenticated users
prefix: 'rl:auth',
analytics: false,
});
return _authenticatedLimiter;
}

function getPublicLimiter(): Ratelimit | null {
if (_publicLimiter) return _publicLimiter;
const redis = getRedis();
if (!redis) return null;
_publicLimiter = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(20, '1 m'), // 20 req/min for public/anonymous
prefix: 'rl:public',
analytics: false,
});
return _publicLimiter;
}

function getAiLimiter(): Ratelimit | null {
if (_aiLimiter) return _aiLimiter;
const redis = getRedis();
if (!redis) return null;
_aiLimiter = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10, '1 m'), // 10 req/min for AI endpoints
prefix: 'rl:ai',
analytics: false,
});
return _aiLimiter;
}

// ---------------------------------------------------------------------------
// Identifier helpers
// ---------------------------------------------------------------------------
function getClientIp(request: NextRequest): string {
return (
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
request.headers.get('x-real-ip') ||
'unknown'
);
}

// ---------------------------------------------------------------------------
// Rate-limit check helpers
// Returns a 429 NextResponse when limit is exceeded, null otherwise.
// If Redis is not configured, rate limiting is silently skipped (fail-open).
// ---------------------------------------------------------------------------

export async function checkAuthenticatedRateLimit(
_request: NextRequest,
userId: string,
): Promise<NextResponse | null> {
const limiter = getAuthenticatedLimiter();
if (!limiter) return null; // Redis not configured – skip

const { success, limit, remaining, reset } = await limiter.limit(userId);
if (success) return null;

return NextResponse.json(
{ error: 'Too many requests. Please slow down.' },
{
status: 429,
headers: {
'X-RateLimit-Limit': String(limit),
'X-RateLimit-Remaining': String(remaining),
'X-RateLimit-Reset': String(reset),
'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)),
},
},
);
}

export async function checkPublicRateLimit(
request: NextRequest,
): Promise<NextResponse | null> {
const limiter = getPublicLimiter();
if (!limiter) return null;

const ip = getClientIp(request);
const { success, limit, remaining, reset } = await limiter.limit(ip);
if (success) return null;

return NextResponse.json(
{ error: 'Too many requests. Please slow down.' },
{
status: 429,
headers: {
'X-RateLimit-Limit': String(limit),
'X-RateLimit-Remaining': String(remaining),
'X-RateLimit-Reset': String(reset),
'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)),
},
},
);
}

export async function checkAiRateLimit(
_request: NextRequest,
userId: string,
): Promise<NextResponse | null> {
const limiter = getAiLimiter();
if (!limiter) return null;

const { success, limit, remaining, reset } = await limiter.limit(userId);
if (success) return null;

return NextResponse.json(
{ error: 'AI rate limit exceeded. Please wait before sending another query.' },
{
status: 429,
headers: {
'X-RateLimit-Limit': String(limit),
'X-RateLimit-Remaining': String(remaining),
'X-RateLimit-Reset': String(reset),
'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)),
},
},
);
}
5 changes: 5 additions & 0 deletions app/api/ai/ask/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@ import { NextRequest, NextResponse } from 'next/server';
import { buildAnswerContext, normalizeSearchMode, searchResourceChunks } from '../../_utils/aiSearch';
import { isAuthError, requireAuth, unauthorizedResponse } from '../../_utils/auth';
import { generateAnswer } from '../../_utils/gemini';
import { checkAiRateLimit } from '../../_utils/rateLimit';

export async function POST(request: NextRequest) {
try {
const authUser = await requireAuth(request);

// Rate limit: 10 AI queries per minute per authenticated user
const rateLimitResponse = await checkAiRateLimit(request, authUser.uid);
if (rateLimitResponse) return rateLimitResponse;
const { question, mode, limit } = await request.json();

if (!question || typeof question !== 'string') {
Expand Down
6 changes: 6 additions & 0 deletions app/api/public-resources/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,17 @@ import { NextRequest, NextResponse } from 'next/server';
import { isAuthError, requireAuth, unauthorizedResponse } from '../_utils/auth';
import { getServerFirestore } from '../_utils/firebaseAdmin';
import { indexResource } from '../_utils/resourceIndexer';
import { checkAuthenticatedRateLimit } from '../_utils/rateLimit';

// GET /api/public-resources - Get public resources from other users
export async function GET(request: NextRequest) {
try {
const authUser = await requireAuth(request);

// Rate limit: 60 authenticated requests per minute per user
const rateLimitResponse = await checkAuthenticatedRateLimit(request, authUser.uid);
if (rateLimitResponse) return rateLimitResponse;

const db = getServerFirestore();

// Get all public resources except current user's
Expand Down
9 changes: 9 additions & 0 deletions app/api/resources/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { isAuthError, requireAuth, unauthorizedResponse } from '../_utils/auth';
import { getServerFirestore } from '../_utils/firebaseAdmin';
import { getPreviewFromUrl } from '../_utils/linkPreview';
import { checkAuthenticatedRateLimit, checkPublicRateLimit } from '../_utils/rateLimit';
import { indexResource } from '../_utils/resourceIndexer';

// GET /api/resources - Get the authenticated user's resources
Expand All @@ -14,6 +15,9 @@ export async function GET(request: NextRequest) {
const db = getServerFirestore();

if (username && isPublicParam) {
// Rate limit: 20 public requests per minute per IP
const rateLimitResponse = await checkPublicRateLimit(request);
if (rateLimitResponse) return rateLimitResponse;
const usernameQuery = await db.collection('users')
.where('username', '==', username.toLowerCase())
.get();
Expand Down Expand Up @@ -44,6 +48,11 @@ export async function GET(request: NextRequest) {
}

const authUser = await requireAuth(request);

// Rate limit: 60 authenticated requests per minute per user
const authRateLimitResponse = await checkAuthenticatedRateLimit(request, authUser.uid);
if (authRateLimitResponse) return authRateLimitResponse;

const collectionId = searchParams.get('collectionId');

let query: FirebaseFirestore.Query = db.collection('resources')
Expand Down
58 changes: 57 additions & 1 deletion package-lock.json

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

Loading
Loading