Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
44 changes: 44 additions & 0 deletions __tests__/lib/rate-limit-upstash.mock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { upstashLimit } from '@/lib/rate-limit-upstash';

const originalUrl = process.env.UPSTASH_REDIS_REST_URL;
const originalToken = process.env.UPSTASH_REDIS_REST_TOKEN;
// Ensure env vars are set for the adapter to initialize (mocked services will be used)
process.env.UPSTASH_REDIS_REST_URL = process.env.UPSTASH_REDIS_REST_URL || 'http://localhost';
process.env.UPSTASH_REDIS_REST_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN || 'test-token';

jest.mock('@upstash/redis', () => ({
Redis: jest.fn().mockImplementation(() => ({})),
}), { virtual: true });

jest.mock('@upstash/ratelimit', () => {
const slidingWindow = jest.fn().mockImplementation((_max: number, _win: string) => ({}));
const Ratelimit = jest.fn().mockImplementation(() => ({
limit: async (_id: string) => ({ success: true, remaining: 2, resetAfter: 10000 }),
}));
// Attach static helper
(Ratelimit as any).slidingWindow = slidingWindow;
return { Ratelimit };
}, { virtual: true });

describe('Upstash adapter (mocked)', () => {
afterAll(() => {
if (originalUrl === undefined) {
delete process.env.UPSTASH_REDIS_REST_URL;
} else {
process.env.UPSTASH_REDIS_REST_URL = originalUrl;
}

if (originalToken === undefined) {
delete process.env.UPSTASH_REDIS_REST_TOKEN;
} else {
process.env.UPSTASH_REDIS_REST_TOKEN = originalToken;
}
});

it('returns mapped rate limit result', async () => {
const res = await upstashLimit('test-id', { maxRequests: 5, windowMs: 60 * 60 * 1000 });
expect(res.success).toBe(true);
expect(typeof res.remaining).toBe('number');
expect(typeof res.reset).toBe('number');
});
});
32 changes: 32 additions & 0 deletions __tests__/lib/rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { checkRateLimit as checkLimitInmem, RateLimitConfig } from '@/lib/rate-limit';

describe('In-memory rate limiter (sanity checks)', () => {
const originalRateLimitMode = process.env.RATE_LIMIT_MODE;

afterAll(() => {
if (originalRateLimitMode === undefined) {
delete process.env.RATE_LIMIT_MODE;
} else {
process.env.RATE_LIMIT_MODE = originalRateLimitMode;
}
});

it('should allow requests up to the limit and then block', async () => {
process.env.RATE_LIMIT_MODE = 'INMEM';

const key = `test:${Date.now()}:${Math.random()}`;
const config: RateLimitConfig = { maxRequests: 3, windowMs: 1000 * 60 };

const r1 = await checkLimitInmem(key, config);
expect(r1.success).toBe(true);

const r2 = await checkLimitInmem(key, config);
expect(r2.success).toBe(true);

const r3 = await checkLimitInmem(key, config);
expect(r3.success).toBe(true);

const r4 = await checkLimitInmem(key, config);
expect(r4.success).toBe(false);
});
});
35 changes: 34 additions & 1 deletion app/api/contact/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { PrismaClient } from "@prisma/client";
import { checkRateLimit, getClientIP } from "@/lib/rate-limit";

// Singleton pattern for Prisma Client to avoid connection pool exhaustion
const globalForPrisma = global as unknown as { prisma: PrismaClient };
Expand All @@ -9,6 +10,31 @@ const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

export async function POST(request: NextRequest) {
// Rate limiting: 5 requests per hour per IP
const clientIP = getClientIP(request);
const rateLimitKey = `${clientIP}:contact`;
const rateLimitResult = checkRateLimit(rateLimitKey, {
maxRequests: 5,
windowMs: 60 * 60 * 1000, // 1 hour
});

if (!rateLimitResult.success) {
return NextResponse.json(
{
error: "Too many requests. Please try again later.",
resetAt: new Date(rateLimitResult.reset).toISOString()
},
{
status: 429,
headers: {
'X-RateLimit-Limit': '5',
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}

try {
const body = await request.json();
const { name, email, subject, message } = body;
Expand Down Expand Up @@ -50,7 +76,14 @@ export async function POST(request: NextRequest) {
message: "Thank you for contacting us! We'll get back to you soon.",
id: contactSubmission.id,
},
{ status: 200 }
{
status: 200,
headers: {
'X-RateLimit-Limit': '5',
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
},
}
);
} catch (error) {
console.error("Contact form submission error:", error);
Expand Down
50 changes: 47 additions & 3 deletions app/api/newsletter/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
import { PrismaClient } from "@prisma/client";
import { checkRateLimit, getClientIP } from "@/lib/rate-limit";

const prisma = new PrismaClient();
// Singleton pattern for Prisma Client to avoid connection pool exhaustion
const globalForPrisma = global as unknown as { prisma: PrismaClient };

const prisma = globalForPrisma.prisma || new PrismaClient();

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

// POST /api/newsletter - Subscribe to newsletter
export async function POST(request: NextRequest) {
// Rate limiting: 3 requests per hour per IP
const clientIP = getClientIP(request);
const rateLimitResult = checkRateLimit(clientIP + ':newsletter', {
maxRequests: 3,
windowMs: 60 * 60 * 1000, // 1 hour
});

if (!rateLimitResult.success) {
return NextResponse.json(
{
error: "Too many subscription attempts. Please try again later.",
resetAt: new Date(rateLimitResult.reset).toISOString()
},
{
status: 429,
headers: {
'X-RateLimit-Limit': '3',
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}

try {
const body = await request.json();
const { email, name, source } = body;
Expand Down Expand Up @@ -36,7 +66,14 @@ export async function POST(request: NextRequest) {

return NextResponse.json(
{ message: "Welcome back! You've been resubscribed.", resubscribed: true },
{ status: 200 }
{
status: 200,
headers: {
'X-RateLimit-Limit': '3',
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
},
}
);
}

Expand Down Expand Up @@ -68,7 +105,14 @@ export async function POST(request: NextRequest) {
email: subscriber.email,
},
},
{ status: 201 }
{
status: 201,
headers: {
'X-RateLimit-Limit': '3',
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
},
}
);
} catch (error) {
console.error("Newsletter subscription error:", error);
Expand Down
43 changes: 41 additions & 2 deletions app/api/user-stats/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { NextResponse } from 'next/server';
import { auth } from '@clerk/nextjs/server';
import { PrismaClient } from '@prisma/client';
import { checkRateLimit } from '@/lib/rate-limit';

const prisma = new PrismaClient();
// Singleton pattern for Prisma Client to avoid connection pool exhaustion
const globalForPrisma = global as unknown as { prisma: PrismaClient };

const prisma = globalForPrisma.prisma || new PrismaClient();

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

export async function GET() {
try {
Expand All @@ -12,6 +18,29 @@ export async function GET() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

// Rate limiting: 60 requests per minute per user
const rateLimitResult = checkRateLimit(userId + ':stats', {
maxRequests: 60,
windowMs: 60 * 1000, // 1 minute
});

if (!rateLimitResult.success) {
return NextResponse.json(
{
error: "Too many requests. Please slow down.",
resetAt: new Date(rateLimitResult.reset).toISOString()
},
{
status: 429,
headers: {
'X-RateLimit-Limit': '60',
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}

// Try to find existing user stats
let user = await prisma.user.findUnique({
where: { id: userId },
Expand Down Expand Up @@ -60,7 +89,17 @@ export async function GET() {
});
}

return NextResponse.json({ stats: user?.stats });
return NextResponse.json(
{ stats: user?.stats },
{
status: 200,
headers: {
'X-RateLimit-Limit': '60',
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
},
}
);
} catch (error) {
console.error('Error fetching user stats:', error);
return NextResponse.json(
Expand Down
27 changes: 17 additions & 10 deletions app/dashboard/resume/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,16 +188,23 @@ export default function ResumeBuilderPage() {

// Load resume from localStorage
const loadResume = () => {
const savedResume = localStorage.getItem("devPocketResume");
if (savedResume) {
const resumeData = JSON.parse(savedResume);
setPersonalInfo(resumeData.personalInfo);
setEducation(resumeData.education);
setExperience(resumeData.experience);
setSkills(resumeData.skills);
alert("Resume loaded successfully!");
} else {
alert("No saved resume found.");
try {
const savedResume = localStorage.getItem("devPocketResume");
if (savedResume) {
const resumeData = JSON.parse(savedResume);
setPersonalInfo(resumeData.personalInfo);
setEducation(resumeData.education);
setExperience(resumeData.experience);
setSkills(resumeData.skills);
alert("Resume loaded successfully!");
} else {
alert("No saved resume found.");
}
} catch (error) {
console.error('Failed to parse saved resume:', error);
// Clear corrupted data
localStorage.removeItem("devPocketResume");
alert("Resume data was corrupted and has been reset. Please create a new resume.");
}
};

Expand Down
13 changes: 10 additions & 3 deletions app/job/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,16 @@ export default function JobSearchPage() {

// Load saved jobs from localStorage
useEffect(() => {
const saved = localStorage.getItem('savedJobs');
if (saved) {
setSavedJobs(new Set(JSON.parse(saved)));
try {
const saved = localStorage.getItem('savedJobs');
if (saved) {
setSavedJobs(new Set(JSON.parse(saved)));
}
} catch (error) {
console.error('Failed to parse saved jobs:', error);
// Clear corrupted data
localStorage.removeItem('savedJobs');
setSavedJobs(new Set());
}
}, []);

Expand Down
Loading