diff --git a/app/api/contributions/route.test.ts b/app/api/contributions/route.test.ts new file mode 100644 index 000000000..60db887f8 --- /dev/null +++ b/app/api/contributions/route.test.ts @@ -0,0 +1,157 @@ +// app/api/contributions/route.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GET } from './route'; + +vi.mock('@/lib/github', () => ({ + fetchGitHubContributions: vi.fn(), +})); + +vi.mock('@/lib/githubtoken', () => ({ + getUserGitHubToken: vi.fn().mockResolvedValue(undefined), +})); + +import { fetchGitHubContributions } from '@/lib/github'; +import { quotaMonitor } from '@/services/github/quota-monitor'; +import { refreshPolicy } from '@/services/github/refresh-policy'; +import { refreshRateLimiter } from '@/services/github/refresh-rate-limiter'; + +const mockContributionData = { + totalContributions: 150, + calendar: { + totalContributions: 150, + weeks: [], + }, + repoContributions: [], +}; + +function makeRequest( + params: Record = {}, + headers: Record = {} +): Request { + const url = new URL('http://localhost/api/contributions'); + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + return new Request(url.toString(), { + headers: new Headers(headers), + }); +} + +describe('GET /api/contributions', () => { + beforeEach(() => { + vi.restoreAllMocks(); + process.env.TRUSTED_PROXIES = '*'; + quotaMonitor.reset(); + refreshPolicy.reset(); + refreshRateLimiter.reset(); + vi.mocked(fetchGitHubContributions).mockResolvedValue( + mockContributionData as unknown as Awaited> + ); + }); + + it('returns 400 when username or user parameter is missing', async () => { + const response = await GET(makeRequest({}, { 'x-forwarded-for': '10.0.0.1' })); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toContain('Missing required parameter'); + expect(fetchGitHubContributions).not.toHaveBeenCalled(); + }); + + it('returns 400 when username is invalid', async () => { + const response = await GET( + makeRequest({ username: 'invalid/user' }, { 'x-forwarded-for': '10.0.0.2' }) + ); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe('Invalid GitHub username'); + expect(fetchGitHubContributions).not.toHaveBeenCalled(); + }); + + it('returns 200 with contribution data for valid username', async () => { + const response = await GET( + makeRequest({ username: 'torvalds' }, { 'x-forwarded-for': '10.0.0.3' }) + ); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body).toEqual(mockContributionData); + expect(fetchGitHubContributions).toHaveBeenCalledWith('torvalds', expect.anything()); + }); + + it('accepts user query parameter as alternative to username', async () => { + const response = await GET( + makeRequest({ user: 'torvalds' }, { 'x-forwarded-for': '10.0.0.4' }) + ); + expect(response.status).toBe(200); + expect(fetchGitHubContributions).toHaveBeenCalledWith('torvalds', expect.anything()); + }); + + it('enforces per-IP rate limiting (10 req/min) returning 429 and Retry-After header', async () => { + const headers = { 'x-forwarded-for': '198.51.100.10' }; + + // First 10 requests should succeed + for (let i = 0; i < 10; i++) { + const res = await GET(makeRequest({ username: 'torvalds' }, headers)); + expect(res.status).toBe(200); + } + + // 11th request from same IP should be rate limited + const limitedRes = await GET(makeRequest({ username: 'torvalds' }, headers)); + expect(limitedRes.status).toBe(429); + const body = await limitedRes.json(); + expect(body.error).toBe('Too many requests. Please try again later.'); + expect(limitedRes.headers.has('Retry-After')).toBe(true); + expect(limitedRes.headers.has('X-RateLimit-Limit')).toBe(true); + expect(limitedRes.headers.get('X-RateLimit-Remaining')).toBe('0'); + }); + + it('allows custom token via Authorization header and bypasses shared IP rate limit', async () => { + const headers = { + 'x-forwarded-for': '198.51.100.20', + authorization: 'Bearer ghp_customtoken12345', + }; + + const response = await GET(makeRequest({ username: 'torvalds' }, headers)); + expect(response.status).toBe(200); + expect(fetchGitHubContributions).toHaveBeenCalledWith( + 'torvalds', + expect.objectContaining({ token: 'ghp_customtoken12345' }) + ); + }); + + it('allows custom token via x-github-token header', async () => { + const headers = { + 'x-forwarded-for': '198.51.100.30', + 'x-github-token': 'ghp_customtoken67890', + }; + + const response = await GET(makeRequest({ username: 'torvalds' }, headers)); + expect(response.status).toBe(200); + expect(fetchGitHubContributions).toHaveBeenCalledWith( + 'torvalds', + expect.objectContaining({ token: 'ghp_customtoken67890' }) + ); + }); + + it('allows custom token via query parameter', async () => { + const response = await GET( + makeRequest( + { username: 'torvalds', token: 'ghp_querytoken' }, + { 'x-forwarded-for': '198.51.100.40' } + ) + ); + expect(response.status).toBe(200); + expect(fetchGitHubContributions).toHaveBeenCalledWith( + 'torvalds', + expect.objectContaining({ token: 'ghp_querytoken' }) + ); + }); + + it('returns 404 when user is not found', async () => { + const headers = { 'x-forwarded-for': '10.0.0.5' }; + vi.mocked(fetchGitHubContributions).mockRejectedValueOnce(new Error('User not found')); + const response = await GET(makeRequest({ username: 'nonexistentuser' }, headers)); + expect(response.status).toBe(404); + const body = await response.json(); + expect(body.error).toBe('User not found'); + }); +}); diff --git a/app/api/contributions/route.ts b/app/api/contributions/route.ts new file mode 100644 index 000000000..f352811c9 --- /dev/null +++ b/app/api/contributions/route.ts @@ -0,0 +1,201 @@ +// app/api/contributions/route.ts + +import { NextResponse } from 'next/server'; +import { fetchGitHubContributions } from '@/lib/github'; +import { githubUsernameSchema, coerceQueryParams } from '@/lib/validations'; +import { getClientIp } from '@/utils/getClientIp'; +import { RateLimiter, getRateLimitHeaders } from '@/lib/rate-limit'; +import { quotaMonitor } from '@/services/github/quota-monitor'; +import { refreshRateLimiter } from '@/services/github/refresh-rate-limiter'; +import { refreshPolicy } from '@/services/github/refresh-policy'; +import { getUserGitHubToken } from '@/lib/githubtoken'; +import logger from '@/lib/logger'; +import { z } from 'zod'; + +const contributionsLimiter = new RateLimiter(10, 60_000, 1000); + +const contributionsParamsSchema = z.object({ + username: z.string().optional(), + user: z.string().optional(), + refresh: z.preprocess((val) => val === 'true' || val === '1', z.boolean()).default(false), + bypassCache: z.preprocess((val) => val === 'true' || val === '1', z.boolean()).default(false), + excludeBots: z.preprocess((val) => val === 'true' || val === '1', z.boolean()).default(false), + token: z.string().optional(), +}); + +function getCallerToken(request: Request, searchParamToken?: string): string | undefined { + if (searchParamToken && searchParamToken.trim().length > 0) { + return searchParamToken.trim(); + } + + const authHeader = request.headers.get('authorization'); + if (authHeader && authHeader.toLowerCase().startsWith('bearer ')) { + const token = authHeader.substring(7).trim(); + if (token) return token; + } + + const customHeader = request.headers.get('x-github-token'); + if (customHeader && customHeader.trim().length > 0) { + return customHeader.trim(); + } + + return undefined; +} + +/** + * Returns GitHub contribution data for a given user. + * + * Query params: + * - username / user: GitHub username to fetch contribution statistics for + * - refresh / bypassCache: Optional boolean to bypass cache + * - token: Optional custom GitHub personal access token + * + * Header options for high-volume usage: + * - Authorization: Bearer + * - x-github-token: + */ +export async function GET(request: Request) { + const ip = getClientIp(request); + const { searchParams } = new URL(request.url); + const queryObj = coerceQueryParams(searchParams); + const parseResult = contributionsParamsSchema.safeParse(queryObj); + + if (!parseResult.success) { + return NextResponse.json( + { error: 'Invalid parameters', details: parseResult.error.flatten() }, + { status: 400 } + ); + } + + const rawUsername = parseResult.data.username || parseResult.data.user; + if (!rawUsername) { + return NextResponse.json( + { error: 'Missing required parameter: "username" or "user"' }, + { status: 400 } + ); + } + + const usernameCheck = githubUsernameSchema.safeParse(rawUsername); + if (!usernameCheck.success) { + return NextResponse.json( + { error: 'Invalid GitHub username', details: usernameCheck.error.flatten() }, + { status: 400 } + ); + } + const username = usernameCheck.data; + + const callerToken = getCallerToken(request, parseResult.data.token); + const userSessionToken = await getUserGitHubToken(); + const effectiveToken = callerToken || userSessionToken; + + // Rate limiting check: + // If caller supplies their own GitHub token, allow higher usage; + // otherwise enforce per-IP rate limiting (10 req/min) to protect shared PAT quota. + if (!callerToken) { + const rateLimitKey = + ip && ip !== 'unknown' ? ip : `unknown:${request.headers.get('user-agent') ?? 'no-agent'}`; + const limitResult = + typeof contributionsLimiter.checkWithResult === 'function' + ? await contributionsLimiter.checkWithResult(rateLimitKey) + : { + success: await contributionsLimiter.check(rateLimitKey), + limit: 10, + remaining: 0, + reset: Date.now() + 60000, + }; + if (!limitResult.success) { + return NextResponse.json( + { error: 'Too many requests. Please try again later.' }, + { + status: 429, + headers: getRateLimitHeaders(limitResult), + } + ); + } + } + + const isRefreshRequested = parseResult.data.refresh || parseResult.data.bypassCache; + + if (isRefreshRequested && quotaMonitor.isQuotaLow()) { + return NextResponse.json( + { error: 'GitHub API quota is low. Cache refresh temporarily disabled.' }, + { status: 429, headers: { 'Retry-After': '60' } } + ); + } + + if (isRefreshRequested) { + const rateLimitCheck = refreshRateLimiter.checkLimit(ip); + if (!rateLimitCheck.success) { + return NextResponse.json( + { error: 'Refresh rate limit exceeded. Please try again later.' }, + { status: 429, headers: getRateLimitHeaders(rateLimitCheck) } + ); + } + } + + let shouldBypassCache = isRefreshRequested; + if (isRefreshRequested) { + if (!refreshPolicy.isRefreshAllowed(username)) { + shouldBypassCache = false; + } else { + refreshPolicy.recordRefresh(username); + } + } + + try { + const data = await fetchGitHubContributions(username, { + bypassCache: shouldBypassCache, + token: effectiveToken, + excludeBots: parseResult.data.excludeBots, + }); + + const cacheControl = shouldBypassCache + ? 'no-cache, no-store, must-revalidate' + : 's-maxage=60, stale-while-revalidate=300'; + + return NextResponse.json(data, { + status: 200, + headers: { + 'Cache-Control': cacheControl, + 'X-Cache-Status': shouldBypassCache ? 'MISS' : 'HIT', + }, + }); + } catch (error: unknown) { + const err = error as { status?: number; response?: { status?: number }; message?: string }; + const status = err.status || err.response?.status; + const message = err.message || ''; + + if ( + status === 404 || + message.toLowerCase().includes('user not found') || + message.toLowerCase().includes('could not resolve') + ) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + if ( + status === 401 || + message.includes('401') || + message.toLowerCase().includes('bad credentials') + ) { + return NextResponse.json({ error: 'GitHub token is invalid or missing.' }, { status: 401 }); + } + + if ( + status === 403 || + message.toLowerCase().includes('rate limit') || + message.includes('API Rate Limit Exceeded') + ) { + return NextResponse.json( + { error: 'GitHub API rate limit reached. Please try again later.' }, + { status: 429, headers: { 'Retry-After': '60' } } + ); + } + + logger.error('Unhandled error in GET /api/contributions', { error }); + return NextResponse.json( + { error: 'An unexpected error occurred. Please try again.' }, + { status: 500 } + ); + } +} diff --git a/app/api/github/route.test.ts b/app/api/github/route.test.ts index 4c32a1c2b..368debbe7 100644 --- a/app/api/github/route.test.ts +++ b/app/api/github/route.test.ts @@ -44,6 +44,12 @@ function makeRequest( beforeEach(() => { vi.clearAllMocks(); + vi.spyOn(RateLimiter.prototype, 'checkWithResult').mockResolvedValue({ + success: true, + limit: 10, + remaining: 9, + reset: Date.now() + 60000, + }); vi.spyOn(RateLimiter.prototype, 'check').mockResolvedValue(true); vi.mocked(getFullDashboardData).mockResolvedValue({ profile: { lastSyncedAt: new Date().toISOString() }, diff --git a/app/api/github/route.ts b/app/api/github/route.ts index 4bc305827..9248352d4 100644 --- a/app/api/github/route.ts +++ b/app/api/github/route.ts @@ -69,10 +69,23 @@ export async function GET(request: Request) { const rateLimitKey = ip && ip !== 'unknown' ? ip : `unknown:${request.headers.get('user-agent') ?? 'no-agent'}`; - if (!(await dashboardLimiter.check(rateLimitKey))) { + const limitResult = + typeof dashboardLimiter.checkWithResult === 'function' + ? await dashboardLimiter.checkWithResult(rateLimitKey) + : { + success: await dashboardLimiter.check(rateLimitKey), + limit: 10, + remaining: 0, + reset: Date.now() + 60000, + }; + + if (!limitResult.success) { return NextResponse.json( { error: 'Too many requests. Please try again later.' }, - { status: 429 } + { + status: 429, + headers: getRateLimitHeaders(limitResult), + } ); } diff --git a/middleware.rate-limit.test.ts b/middleware.rate-limit.test.ts index c7bc8500e..286d0e6b5 100644 --- a/middleware.rate-limit.test.ts +++ b/middleware.rate-limit.test.ts @@ -98,6 +98,7 @@ describe('Middleware rate-limit consistency', () => { const expectedRoutes = [ '/api/streak/:path*', '/api/github/:path*', + '/api/contributions/:path*', '/api/track-user/:path*', '/api/stats/:path*', '/api/og/:path*', diff --git a/middleware.ts b/middleware.ts index 13677155b..2466d302c 100644 --- a/middleware.ts +++ b/middleware.ts @@ -46,6 +46,10 @@ const routeRules: RouteRule[] = [ pattern: '/api/notify', rateLimit: { limit: 5, windowMs: 60000, namespace: 'notify' }, }, + { + pattern: '/api/contributions', + rateLimit: { limit: 10, windowMs: 60000, namespace: 'contributions' }, + }, ]; const ROUTES_WITH_OWN_RATE_LIMITING = [ @@ -63,6 +67,7 @@ const ROUTES_WITH_OWN_RATE_LIMITING = [ '/api/spotify', // Added here in case it has its own rate limiter '/api/languages', '/api/tech-stack', + '/api/contributions', ]; function addSecurityHeaders(response: NextResponse): NextResponse { @@ -203,6 +208,7 @@ export const config = { matcher: [ '/api/streak/:path*', '/api/github/:path*', + '/api/contributions/:path*', '/api/languages/:path*', '/api/track-user/:path*', '/api/stats/:path*',