Skip to content

Commit 039f78a

Browse files
authored
fix(api): add rate limiting, Zod validation, and MONGODB_URI handling to /api/notify (JhaSourav07#2086)
## Description Fixes JhaSourav07#2067 The `/api/notify` endpoint lacked several safeguards present in other API routes. This PR brings it in line with the rest of the codebase: - **Rate limiting** — Added `notifyRateLimiter` (5 req/min per IP) to both POST and GET handlers - **Zod validation** — Replaced manual `if (!username || !email)` checks with `notifyPostSchema` and `notifyGetSchema` (Zod v4), including GitHub username format validation via `GITHUB_USERNAME_REGEX` - **MONGODB_URI graceful degradation** — Production returns 500 with a clear error; development bypasses gracefully - **Middleware matcher** — Added `/api/notify/:path*` to the middleware config for global rate limiting - **Malformed JSON handling** — Safely catches `req.json()` parse failures ### Files changed | File | Change | |------|--------| | `app/api/notify/route.ts` | Rewrote with rate limiting, Zod validation, MONGODB_URI handling | | `app/api/notify/route.test.ts` | Expanded from 8 to 19 tests covering all new behaviors | | `lib/validations.ts` | Added `notifyPostSchema`, `notifyGetSchema`, exported `GITHUB_USERNAME_REGEX` | | `lib/rate-limit.ts` | Added `notifyRateLimiter` instance | | `middleware.ts` | Added `/api/notify/:path*` to matcher | ## Pillar - [x] 🛠️ Other (Bug fix, refactoring, docs) ## Visual Preview No visual changes — this is a validation/security fix for JSON API endpoints. ## Checklist before requesting a review: - [x] I have read the `CONTRIBUTING.md` file. - [x] I have tested these changes locally (`localhost:3000/api/streak?user=YOUR_USERNAME`). - [x] I have run `npm run format` and `npm run lint` locally and resolved all errors (CI will fail otherwise). - [x] My commits follow the Conventional Commits format (e.g., `feat(themes): ...`, `fix(calculate): ...`). - [ ] I have updated `README.md` if I added a new theme or URL parameter. - [x] I have started the repo. - [x] I have made sure that i have only one commit to merge in this PR. - [x] The SVG output matches the CommitPulse "premium quality" aesthetic standard (no raw elements, smooth animations, correct fonts).
2 parents 5b430da + 612c8b6 commit 039f78a

5 files changed

Lines changed: 298 additions & 40 deletions

File tree

app/api/notify/route.test.ts

Lines changed: 145 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect, vi, beforeEach } from 'vitest';
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
22
import { NextRequest } from 'next/server';
33
import { GET, POST } from './route';
44

@@ -10,19 +10,38 @@ vi.mock('@/models/Notification', () => ({
1010
findOne: vi.fn(),
1111
},
1212
}));
13+
vi.mock('@/lib/rate-limit', () => ({
14+
notifyRateLimiter: {
15+
check: vi.fn().mockResolvedValue(true),
16+
},
17+
}));
1318

1419
import { Notification } from '@/models/Notification';
20+
import { notifyRateLimiter } from '@/lib/rate-limit';
1521

1622
const makeRequest = (method: string, body?: object, search?: string) => {
1723
const url = `http://localhost:3000/api/notify${search ? '?' + search : ''}`;
1824
return new NextRequest(url, {
1925
method,
26+
headers: { 'x-forwarded-for': '127.0.0.1' },
2027
body: body ? JSON.stringify(body) : undefined,
2128
});
2229
};
2330

2431
describe('POST /api/notify', () => {
25-
beforeEach(() => vi.clearAllMocks());
32+
const originalEnv = process.env;
33+
34+
beforeEach(() => {
35+
vi.clearAllMocks();
36+
process.env = { ...originalEnv, MONGODB_URI: 'mongodb://localhost/test' };
37+
vi.mocked(notifyRateLimiter.check).mockResolvedValue(true);
38+
});
39+
40+
afterEach(() => {
41+
process.env = originalEnv;
42+
});
43+
44+
// ── Validation ────────────────────────────────────────────────────────────
2645

2746
it('returns 400 when username is missing', async () => {
2847
const res = await POST(makeRequest('POST', { email: 'test@test.com' }));
@@ -46,6 +65,62 @@ describe('POST /api/notify', () => {
4665
expect(res.status).toBe(400);
4766
});
4867

68+
it('returns 400 for invalid GitHub username format', async () => {
69+
const res = await POST(makeRequest('POST', { username: '-invalid', email: 'a@b.com' }));
70+
expect(res.status).toBe(400);
71+
const data = await res.json();
72+
expect(data.message).toContain('Invalid GitHub username');
73+
});
74+
75+
it('returns 400 for username exceeding 39 characters', async () => {
76+
const res = await POST(makeRequest('POST', { username: 'a'.repeat(40), email: 'a@b.com' }));
77+
expect(res.status).toBe(400);
78+
});
79+
80+
it('returns 400 for malformed JSON body', async () => {
81+
const url = 'http://localhost:3000/api/notify';
82+
const req = new NextRequest(url, {
83+
method: 'POST',
84+
headers: { 'x-forwarded-for': '127.0.0.1', 'content-type': 'application/json' },
85+
body: 'not-json',
86+
});
87+
const res = await POST(req);
88+
expect(res.status).toBe(400);
89+
const data = await res.json();
90+
expect(data.message).toContain('Malformed JSON');
91+
});
92+
93+
// ── Rate limiting ────────────────────────────────────────────────────────
94+
95+
it('returns 429 when rate limited', async () => {
96+
vi.mocked(notifyRateLimiter.check).mockResolvedValue(false);
97+
const res = await POST(makeRequest('POST', { username: 'testuser', email: 'a@b.com' }));
98+
expect(res.status).toBe(429);
99+
});
100+
101+
// ── MONGODB_URI handling ──────────────────────────────────────────────────
102+
103+
it('returns 500 when MONGODB_URI is not set in production', async () => {
104+
delete process.env.MONGODB_URI;
105+
vi.stubEnv('NODE_ENV', 'production');
106+
const res = await POST(makeRequest('POST', { username: 'testuser', email: 'a@b.com' }));
107+
expect(res.status).toBe(500);
108+
const data = await res.json();
109+
expect(data.message).toContain('Database configuration error');
110+
});
111+
112+
it('bypasses gracefully when MONGODB_URI is not set in development', async () => {
113+
delete process.env.MONGODB_URI;
114+
vi.stubEnv('NODE_ENV', 'development');
115+
const res = await POST(makeRequest('POST', { username: 'testuser', email: 'a@b.com' }));
116+
expect(res.status).toBe(200);
117+
const data = await res.json();
118+
expect(data.success).toBe(true);
119+
expect(data.message).toContain('bypassed');
120+
});
121+
122+
// ── Success ──────────────────────────────────────────────────────────────
123+
49124
it('returns 200 and saves preferences successfully', async () => {
50125
vi.mocked(Notification.findOneAndUpdate).mockResolvedValue({
51126
username: 'testuser',
@@ -65,17 +140,81 @@ describe('POST /api/notify', () => {
65140
})
66141
);
67142
expect(res.status).toBe(200);
143+
const data = await res.json();
144+
expect(data.success).toBe(true);
145+
expect(data.data.username).toBe('testuser');
146+
});
147+
148+
it('defaults frequency to daily and preferences to true when omitted', async () => {
149+
vi.mocked(Notification.findOneAndUpdate).mockResolvedValue({
150+
username: 'testuser',
151+
email: 'a@b.com',
152+
frequency: 'daily',
153+
notifyOnCommit: true,
154+
notifyOnStreak: true,
155+
notifyOnMilestone: true,
156+
} as never);
157+
158+
const res = await POST(makeRequest('POST', { username: 'testuser', email: 'a@b.com' }));
159+
expect(res.status).toBe(200);
68160
});
69161
});
70162

71163
describe('GET /api/notify', () => {
72-
beforeEach(() => vi.clearAllMocks());
164+
const originalEnv = process.env;
165+
166+
beforeEach(() => {
167+
vi.clearAllMocks();
168+
process.env = { ...originalEnv, MONGODB_URI: 'mongodb://localhost/test' };
169+
vi.mocked(notifyRateLimiter.check).mockResolvedValue(true);
170+
});
171+
172+
afterEach(() => {
173+
process.env = originalEnv;
174+
});
175+
176+
// ── Validation ────────────────────────────────────────────────────────────
73177

74178
it('returns 400 when username is missing', async () => {
75179
const res = await GET(makeRequest('GET'));
76180
expect(res.status).toBe(400);
77181
});
78182

183+
it('returns 400 for invalid GitHub username format', async () => {
184+
const res = await GET(makeRequest('GET', undefined, 'user=-invalid'));
185+
expect(res.status).toBe(400);
186+
const data = await res.json();
187+
expect(data.message).toContain('Invalid GitHub username');
188+
});
189+
190+
// ── Rate limiting ────────────────────────────────────────────────────────
191+
192+
it('returns 429 when rate limited', async () => {
193+
vi.mocked(notifyRateLimiter.check).mockResolvedValue(false);
194+
const res = await GET(makeRequest('GET', undefined, 'user=testuser'));
195+
expect(res.status).toBe(429);
196+
});
197+
198+
// ── MONGODB_URI handling ──────────────────────────────────────────────────
199+
200+
it('returns 500 when MONGODB_URI is not set in production', async () => {
201+
delete process.env.MONGODB_URI;
202+
vi.stubEnv('NODE_ENV', 'production');
203+
const res = await GET(makeRequest('GET', undefined, 'user=testuser'));
204+
expect(res.status).toBe(500);
205+
});
206+
207+
it('bypasses gracefully when MONGODB_URI is not set in development', async () => {
208+
delete process.env.MONGODB_URI;
209+
vi.stubEnv('NODE_ENV', 'development');
210+
const res = await GET(makeRequest('GET', undefined, 'user=testuser'));
211+
const data = await res.json();
212+
expect(data.success).toBe(false);
213+
expect(data.message).toContain('no database configured');
214+
});
215+
216+
// ── Data fetching ────────────────────────────────────────────────────────
217+
79218
it('returns 404 when user not found', async () => {
80219
vi.mocked(Notification.findOne).mockResolvedValue(null);
81220
const res = await GET(makeRequest('GET', undefined, 'user=nobody'));
@@ -94,6 +233,9 @@ describe('GET /api/notify', () => {
94233

95234
const res = await GET(makeRequest('GET', undefined, 'user=testuser'));
96235
expect(res.status).toBe(200);
236+
const data = await res.json();
237+
expect(data.success).toBe(true);
238+
expect(data.data.username).toBe('testuser');
97239
});
98240

99241
it('masks the email address in GET responses to prevent PII exposure', async () => {

app/api/notify/route.ts

Lines changed: 102 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { NextRequest, NextResponse } from 'next/server';
22
import dbConnect from '@/lib/mongodb';
33
import { Notification } from '@/models/Notification';
4-
import { NotificationPayload, NotificationResponse } from '@/types/index';
4+
import { NotificationResponse } from '@/types/index';
5+
import { notifyPostSchema, notifyGetSchema } from '@/lib/validations';
6+
import { notifyRateLimiter } from '@/lib/rate-limit';
57

68
/**
79
* Masks an email address to prevent PII exposure in unauthenticated responses.
@@ -31,34 +33,61 @@ function maskEmail(email: string): string {
3133
// ─── POST /api/notify ────────────────────────────────────────────────────────
3234
// Register or update email notification preferences for a user
3335
export async function POST(req: NextRequest): Promise<NextResponse<NotificationResponse>> {
36+
// Rate limiting
37+
const ip =
38+
req.headers.get('x-forwarded-for')?.split(',')[0] ?? req.headers.get('x-real-ip') ?? 'unknown';
39+
40+
if (ip !== 'unknown' && !(await notifyRateLimiter.check(ip))) {
41+
return NextResponse.json(
42+
{ success: false, message: 'Too many requests, please try again later.' },
43+
{ status: 429 }
44+
);
45+
}
46+
47+
// Parse JSON body safely
48+
let body: unknown;
3449
try {
35-
const body: NotificationPayload = await req.json();
36-
const { username, email, frequency, preferences } = body;
50+
body = await req.json();
51+
} catch {
52+
return NextResponse.json(
53+
{ success: false, message: 'Malformed JSON request body.' },
54+
{ status: 400 }
55+
);
56+
}
3757

38-
// Validate required fields
39-
if (!username || !email) {
40-
return NextResponse.json(
41-
{ success: false, message: 'Username and email are required.' },
42-
{ status: 400 }
43-
);
44-
}
58+
// Validate with Zod
59+
const parsed = notifyPostSchema.safeParse(body);
60+
if (!parsed.success) {
61+
const fieldErrors = parsed.error.flatten();
62+
const firstError =
63+
Object.values(fieldErrors.fieldErrors).flat()[0] ??
64+
fieldErrors.formErrors[0] ??
65+
'Invalid request body.';
66+
return NextResponse.json({ success: false, message: firstError }, { status: 400 });
67+
}
4568

46-
// Validate email format
47-
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
48-
if (!emailRegex.test(email)) {
49-
return NextResponse.json(
50-
{ success: false, message: 'Invalid email address.' },
51-
{ status: 400 }
52-
);
53-
}
69+
const { username, email, frequency, preferences } = parsed.data;
5470

55-
// Validate frequency
56-
const validFrequencies = ['realtime', 'daily', 'weekly'];
57-
if (frequency && !validFrequencies.includes(frequency)) {
58-
return NextResponse.json(
59-
{ success: false, message: 'Invalid frequency. Use realtime, daily, or weekly.' },
60-
{ status: 400 }
71+
try {
72+
// Graceful MONGODB_URI handling
73+
if (!process.env.MONGODB_URI) {
74+
if (process.env.NODE_ENV === 'production') {
75+
console.error(
76+
'CRITICAL: MONGODB_URI is not set in production environment. Notification registration is disabled.'
77+
);
78+
return NextResponse.json(
79+
{ success: false, message: 'Database configuration error.' },
80+
{ status: 500 }
81+
);
82+
}
83+
84+
console.warn(
85+
'MONGODB_URI is not set. Bypassing notification registration for local development.'
6186
);
87+
return NextResponse.json({
88+
success: true,
89+
message: 'Notification registration bypassed (no database configured).',
90+
});
6291
}
6392

6493
await dbConnect();
@@ -68,10 +97,10 @@ export async function POST(req: NextRequest): Promise<NextResponse<NotificationR
6897
{ username: username.toLowerCase() },
6998
{
7099
email: email.toLowerCase(),
71-
frequency: frequency ?? 'daily',
72-
notifyOnCommit: preferences?.notifyOnCommit ?? true,
73-
notifyOnStreak: preferences?.notifyOnStreak ?? true,
74-
notifyOnMilestone: preferences?.notifyOnMilestone ?? true,
100+
frequency,
101+
notifyOnCommit: preferences.notifyOnCommit,
102+
notifyOnStreak: preferences.notifyOnStreak,
103+
notifyOnMilestone: preferences.notifyOnMilestone,
75104
isActive: true,
76105
updatedAt: new Date(),
77106
},
@@ -107,15 +136,52 @@ export async function POST(req: NextRequest): Promise<NextResponse<NotificationR
107136
// ─── GET /api/notify ─────────────────────────────────────────────────────────
108137
// Fetch notification preferences for a user
109138
export async function GET(req: NextRequest): Promise<NextResponse<NotificationResponse>> {
110-
try {
111-
const { searchParams } = new URL(req.url);
112-
const username = searchParams.get('user');
139+
// Rate limiting
140+
const ip =
141+
req.headers.get('x-forwarded-for')?.split(',')[0] ?? req.headers.get('x-real-ip') ?? 'unknown';
113142

114-
if (!username) {
115-
return NextResponse.json(
116-
{ success: false, message: 'Username is required.' },
117-
{ status: 400 }
118-
);
143+
if (ip !== 'unknown' && !(await notifyRateLimiter.check(ip))) {
144+
return NextResponse.json(
145+
{ success: false, message: 'Too many requests, please try again later.' },
146+
{ status: 429 }
147+
);
148+
}
149+
150+
// Validate query params with Zod
151+
const { searchParams } = new URL(req.url);
152+
const parsed = notifyGetSchema.safeParse({
153+
user: searchParams.get('user') ?? undefined,
154+
});
155+
156+
if (!parsed.success) {
157+
const fieldErrors = parsed.error.flatten();
158+
const firstError =
159+
Object.values(fieldErrors.fieldErrors).flat()[0] ??
160+
fieldErrors.formErrors[0] ??
161+
'Invalid request parameters.';
162+
return NextResponse.json({ success: false, message: firstError }, { status: 400 });
163+
}
164+
165+
const { user: username } = parsed.data;
166+
167+
try {
168+
// Graceful MONGODB_URI handling
169+
if (!process.env.MONGODB_URI) {
170+
if (process.env.NODE_ENV === 'production') {
171+
console.error(
172+
'CRITICAL: MONGODB_URI is not set in production environment. Notification lookup is disabled.'
173+
);
174+
return NextResponse.json(
175+
{ success: false, message: 'Database configuration error.' },
176+
{ status: 500 }
177+
);
178+
}
179+
180+
console.warn('MONGODB_URI is not set. Bypassing notification lookup for local development.');
181+
return NextResponse.json({
182+
success: false,
183+
message: 'No notification preferences found (no database configured).',
184+
});
119185
}
120186

121187
await dbConnect();

lib/rate-limit.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,9 @@ export class RateLimiter {
148148
// Global instance for track-user endpoint (5 requests per IP per minute)
149149
export const trackUserRateLimiter = new RateLimiter(5, 60000);
150150

151+
// Global instance for notify endpoint (5 requests per IP per minute)
152+
export const notifyRateLimiter = new RateLimiter(5, 60000);
153+
151154
/**
152155
* Lightweight in-memory rate limiter for Next.js Edge Middleware.
153156
*

0 commit comments

Comments
 (0)