Skip to content
Open
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
157 changes: 157 additions & 0 deletions app/api/contributions/route.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {},
headers: Record<string, string> = {}
): 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<ReturnType<typeof fetchGitHubContributions>>
);
});

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');
});
});
201 changes: 201 additions & 0 deletions app/api/contributions/route.ts
Original file line number Diff line number Diff line change
@@ -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 <token>
* - x-github-token: <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 }
);
}
}
6 changes: 6 additions & 0 deletions app/api/github/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
Expand Down
17 changes: 15 additions & 2 deletions app/api/github/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
);
}

Expand Down
Loading
Loading