Skip to content

Commit bf4b186

Browse files
authored
Merge branch 'main' into test-resumeprofile-type-compiler
2 parents ede13ef + 3a43df2 commit bf4b186

100 files changed

Lines changed: 8933 additions & 211 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/scripts/check-duplicates.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@ function cosineSimilarity(vecA, vecB) {
1818

1919
async function run() {
2020
const geminiApiKey = process.env.GEMINI_API_KEY;
21-
const githubToken = process.env.GITHUB_TOKEN;
21+
const githubToken = process.env.GITHUB_PAT || process.env.GITHUB_TOKEN;
2222

2323
if (!geminiApiKey) {
2424
throw new Error('❌ Missing GEMINI_API_KEY environment variable.');
2525
}
2626
if (!githubToken) {
27-
throw new Error('❌ Missing GITHUB_TOKEN environment variable.');
27+
throw new Error('❌ Missing GITHUB_PAT or GITHUB_TOKEN environment variable.');
2828
}
2929

3030
const octokit = github.getOctokit(githubToken);

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ URL Parameter > Theme Default > System Fallback
191191
| `shading` | `boolean` | No | `false` | Apply intensity-based opacity shading to tower faces so lower intensity levels appear slightly dimmer |
192192
| `opacity` | `number` | No | `1.0` | Global opacity scalar for all tower fill-opacity values (0.1–1.0). `opacity=0.5` = semi-transparent ghost look. `opacity=0.8` = faded, great on light backgrounds. |
193193
| `gradient` | `boolean` | No | `false` | Opt-in to show volumetric gradients on the monolith floor |
194+
| `badges` | `boolean` | No | `false` | Render dynamic glowing milestone badges (e.g., 365-day streak, 1K+ commits) on the SVG |
194195

195196
### Grace Period Examples
196197

app/api/notify/route.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,11 @@ export async function POST(req: NextRequest): Promise<NextResponse<NotificationR
3737
// Rate limiting
3838
const ip = getClientIp(req);
3939

40-
if (ip !== 'unknown' && !(await notifyRateLimiter.check(ip))) {
40+
// fallback ensures rate limit is ALWAYS applied
41+
const rateLimitKey =
42+
ip && ip !== 'unknown' ? ip : `unknown:${req.headers.get('user-agent') ?? 'no-agent'}`;
43+
44+
if (!(await notifyRateLimiter.check(rateLimitKey))) {
4145
return NextResponse.json(
4246
{ success: false, message: 'Too many requests, please try again later.' },
4347
{ status: 429 }

app/api/stats/route.test.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ vi.mock('../../../lib/github', () => ({
77

88
import { fetchGitHubContributions } from '../../../lib/github';
99
import type { ContributionCalendar } from '../../../types';
10+
import { quotaMonitor } from '@/services/github/quota-monitor';
11+
import { refreshPolicy } from '@/services/github/refresh-policy';
12+
import { refreshRateLimiter } from '@/services/github/refresh-rate-limiter';
1013

1114
// Calendar with a known, predictable streak so assertions are deterministic.
1215
// Last day (2024-06-16) has commits; "today" in tests is set to that date.
@@ -27,17 +30,25 @@ const mockCalendar: ContributionCalendar = {
2730
],
2831
};
2932

30-
function makeRequest(params: Record<string, string> = {}): Request {
33+
function makeRequest(
34+
params: Record<string, string> = {},
35+
headers: Record<string, string> = {}
36+
): Request {
3137
const url = new URL('http://localhost/api/stats');
3238
for (const [key, value] of Object.entries(params)) {
3339
url.searchParams.set(key, value);
3440
}
35-
return new Request(url.toString());
41+
return new Request(url.toString(), {
42+
headers: new Headers(headers),
43+
});
3644
}
3745

3846
describe('GET /api/stats', () => {
3947
beforeEach(() => {
4048
vi.clearAllMocks();
49+
quotaMonitor.reset();
50+
refreshPolicy.reset();
51+
refreshRateLimiter.reset();
4152
vi.mocked(fetchGitHubContributions).mockResolvedValue({
4253
calendar: mockCalendar,
4354
repoContributions: [],
@@ -110,6 +121,7 @@ describe('GET /api/stats', () => {
110121
expect(response.headers.get('Cache-Control')).toBe('no-store, no-cache, must-revalidate');
111122
expect(response.headers.get('Pragma')).toBe('no-cache');
112123
expect(response.headers.get('Expires')).toBe('0');
124+
expect(response.headers.get('X-Refresh-Status')).toBe('Fresh');
113125
});
114126

115127
it('still returns valid stats data when refresh=true', async () => {
@@ -143,6 +155,47 @@ describe('GET /api/stats', () => {
143155
expect(fetchGitHubContributions).toHaveBeenCalledWith('testuser', { bypassCache: false });
144156
});
145157

158+
it('serves cached stats instead of bypassing cache during refresh cooldown', async () => {
159+
await GET(makeRequest({ user: 'testuser', refresh: 'true' }));
160+
161+
const response = await GET(makeRequest({ user: 'testuser', refresh: 'true' }));
162+
163+
expect(response.status).toBe(200);
164+
expect(fetchGitHubContributions).toHaveBeenLastCalledWith('testuser', {
165+
bypassCache: false,
166+
});
167+
expect(response.headers.get('X-Refresh-Status')).toBe('Cooldown-Served-Cached');
168+
expect(response.headers.get('Cache-Control')).toBe(
169+
'public, s-maxage=3600, stale-while-revalidate=86400'
170+
);
171+
});
172+
173+
it('returns 429 when stats refresh exceeds the client refresh rate limit', async () => {
174+
refreshRateLimiter.setLimit(1);
175+
176+
await GET(makeRequest({ user: 'testuser', refresh: 'true' }, { 'x-real-ip': '203.0.113.9' }));
177+
178+
const response = await GET(
179+
makeRequest({ user: 'octocat', refresh: 'true' }, { 'x-real-ip': '203.0.113.9' })
180+
);
181+
182+
expect(response.status).toBe(429);
183+
const body = await response.json();
184+
expect(body.error).toContain('Refresh rate limit exceeded');
185+
expect(response.headers.get('X-RateLimit-Limit')).toBe('1');
186+
});
187+
188+
it('blocks stats refresh when the shared GitHub quota is low', async () => {
189+
quotaMonitor.setQuota(5000, 400, Date.now() + 60_000);
190+
191+
const response = await GET(makeRequest({ user: 'testuser', refresh: 'true' }));
192+
193+
expect(response.status).toBe(429);
194+
const body = await response.json();
195+
expect(body.error).toContain('quota is low');
196+
expect(fetchGitHubContributions).not.toHaveBeenCalled();
197+
});
198+
146199
it('accepts a valid IANA timezone without error', async () => {
147200
const response = await GET(makeRequest({ user: 'testuser', tz: 'America/New_York' }));
148201
expect(response.status).toBe(200);

app/api/stats/route.ts

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,21 @@ import { NextResponse } from 'next/server';
33
import { fetchGitHubContributions } from '@/lib/github';
44
import { calculateStreak } from '@/lib/calculate';
55
import { statsParamsSchema } from '@/lib/validations';
6+
import { getClientIp } from '@/utils/getClientIp';
7+
import { quotaMonitor } from '@/services/github/quota-monitor';
8+
import { refreshPolicy } from '@/services/github/refresh-policy';
9+
import { refreshRateLimiter } from '@/services/github/refresh-rate-limiter';
10+
11+
function logSecurityEvent(event: string, details: Record<string, unknown>) {
12+
console.warn(
13+
JSON.stringify({
14+
timestamp: new Date().toISOString(),
15+
type: 'SECURITY_EVENT',
16+
event,
17+
...details,
18+
})
19+
);
20+
}
621

722
/**
823
* GET /api/stats?user=<username>[&refresh=true][&tz=<IANA timezone>]
@@ -20,6 +35,7 @@ import { statsParamsSchema } from '@/lib/validations';
2035
*/
2136
export async function GET(request: Request) {
2237
const { searchParams } = new URL(request.url);
38+
const ip = getClientIp(request);
2339

2440
const parseResult = statsParamsSchema.safeParse(Object.fromEntries(searchParams.entries()));
2541

@@ -47,6 +63,54 @@ export async function GET(request: Request) {
4763

4864
const { user, refresh, tz } = parseResult.data;
4965

66+
if (refresh && quotaMonitor.isQuotaLow()) {
67+
logSecurityEvent('LOW_QUOTA_STATS_REFRESH_BLOCKED', {
68+
user,
69+
ip,
70+
remainingQuota: quotaMonitor.getQuota().remaining,
71+
});
72+
return NextResponse.json(
73+
{ error: 'GitHub API quota is low. Stats refresh temporarily disabled.' },
74+
{ status: 429 }
75+
);
76+
}
77+
78+
if (refresh) {
79+
const rateLimitCheck = refreshRateLimiter.checkLimit(ip);
80+
if (!rateLimitCheck.success) {
81+
logSecurityEvent('STATS_REFRESH_RATE_LIMIT_EXCEEDED', {
82+
user,
83+
ip,
84+
limit: rateLimitCheck.limit,
85+
});
86+
return NextResponse.json(
87+
{ error: 'Refresh rate limit exceeded. Please try again later.' },
88+
{
89+
status: 429,
90+
headers: {
91+
'X-RateLimit-Limit': rateLimitCheck.limit.toString(),
92+
'X-RateLimit-Remaining': rateLimitCheck.remaining.toString(),
93+
'X-RateLimit-Reset': rateLimitCheck.reset.toString(),
94+
},
95+
}
96+
);
97+
}
98+
}
99+
100+
let shouldBypassCache = refresh;
101+
if (refresh) {
102+
if (!refreshPolicy.isRefreshAllowed(user)) {
103+
logSecurityEvent('STATS_REFRESH_COOLDOWN_VIOLATION', {
104+
user,
105+
ip,
106+
remainingMs: refreshPolicy.getRemainingCooldown(user),
107+
});
108+
shouldBypassCache = false;
109+
} else {
110+
refreshPolicy.recordRefresh(user);
111+
}
112+
}
113+
50114
// Validate the optional IANA timezone early so callers get a clear 400
51115
// rather than a silent fallback or a 500.
52116
let timezone = 'UTC';
@@ -59,19 +123,23 @@ export async function GET(request: Request) {
59123
}
60124

61125
try {
62-
const userData = await fetchGitHubContributions(user, { bypassCache: refresh });
126+
const userData = await fetchGitHubContributions(user, { bypassCache: shouldBypassCache });
63127
const calendar = userData.calendar;
64128
const stats = calculateStreak(calendar, timezone);
65129
const headers = new Headers({
66130
// Cache until next UTC midnight; clients can bust with ?refresh=true
67131
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
68132
});
69133

70-
if (refresh) {
134+
if (shouldBypassCache) {
71135
headers.set('Cache-Control', 'no-store, no-cache, must-revalidate');
72136
headers.set('Pragma', 'no-cache');
73137
headers.set('Expires', '0');
74138
}
139+
headers.set(
140+
'X-Refresh-Status',
141+
shouldBypassCache ? 'Fresh' : refresh ? 'Cooldown-Served-Cached' : 'Cached'
142+
);
75143

76144
return NextResponse.json(
77145
{

app/api/streak/route.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -943,7 +943,11 @@ describe('GET /api/streak', () => {
943943
expect(body.details).not.toBeNull();
944944
});
945945
});
946+
it('returns 400 when an invalid hex color is passed as bg', async () => {
947+
const response = await GET(makeRequest({ user: 'octocat', bg: '#ZZZZZZ' }));
946948

949+
expect(response.status).toBe(400);
950+
});
947951
describe('hide parameters', () => {
948952
it('removes the username title when hide_title=true', async () => {
949953
const response = await GET(makeRequest({ user: 'octocat', hide_title: 'true' }));

app/api/streak/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export async function GET(request: Request) {
9999
glow,
100100
format,
101101
days,
102+
badges,
102103
} = parseResult.data;
103104
const normalizedView = view as 'default' | 'monthly' | 'heatmap' | 'pulse';
104105
const themeName = theme || 'dark';
@@ -182,6 +183,7 @@ export async function GET(request: Request) {
182183
disable_particles,
183184
glow,
184185
animate,
186+
badges,
185187
};
186188

187189
let calendar;

app/api/track-user/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ export async function POST(req: Request) {
99
// Get IP for rate limiting securely
1010
const ip = getClientIp(req);
1111

12-
if (ip !== '127.0.0.1' && ip !== 'unknown' && !(await trackUserRateLimiter.check(ip))) {
12+
const rateLimitKey = ip === 'unknown' ? 'unknown-client' : ip;
13+
14+
if (ip !== '127.0.0.1' && !(await trackUserRateLimiter.check(rateLimitKey))) {
1315
return NextResponse.json(
1416
{ success: false, error: 'Too many requests, please try again later.' },
1517
{ status: 429 }

app/api/user-details/route.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { GET } from './route';
3+
4+
vi.mock('@/lib/github', () => ({
5+
fetchUserProfile: vi.fn(),
6+
fetchGitHubContributions: vi.fn(),
7+
}));
8+
9+
import { fetchUserProfile, fetchGitHubContributions } from '@/lib/github';
10+
import type { ContributionCalendar } from '@/types';
11+
12+
const mockCalendar: ContributionCalendar = {
13+
totalContributions: 15,
14+
weeks: [
15+
{
16+
contributionDays: [
17+
{ contributionCount: 5, date: '2024-06-10' },
18+
{ contributionCount: 5, date: '2024-06-11' },
19+
{ contributionCount: 5, date: '2024-06-12' },
20+
],
21+
},
22+
],
23+
};
24+
25+
const mockProfile = {
26+
login: 'testuser',
27+
name: 'Test User',
28+
avatar_url: 'https://github.com/testuser.png',
29+
public_repos: 12,
30+
};
31+
32+
function makeRequest(params: Record<string, string> = {}): Request {
33+
const url = new URL('http://localhost/api/user-details');
34+
for (const [key, value] of Object.entries(params)) {
35+
url.searchParams.set(key, value);
36+
}
37+
return new Request(url.toString());
38+
}
39+
40+
describe('GET /api/user-details', () => {
41+
beforeEach(() => {
42+
vi.clearAllMocks();
43+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
44+
vi.mocked(fetchUserProfile).mockResolvedValue(mockProfile as any);
45+
vi.mocked(fetchGitHubContributions).mockResolvedValue({
46+
calendar: mockCalendar,
47+
repoContributions: [],
48+
totalPRs: 0,
49+
totalIssues: 0,
50+
});
51+
});
52+
53+
it('returns 400 when username is missing', async () => {
54+
const response = await GET(makeRequest());
55+
expect(response.status).toBe(400);
56+
const body = await response.json();
57+
expect(body.error).toBe('Username is required');
58+
});
59+
60+
it('returns 400 when username format is invalid', async () => {
61+
const response = await GET(makeRequest({ username: 'invalid_user_name_@' }));
62+
expect(response.status).toBe(400);
63+
const body = await response.json();
64+
expect(body.error).toBe('Invalid username format');
65+
});
66+
67+
it('returns 200 with user details and streak stats on success', async () => {
68+
const response = await GET(makeRequest({ username: 'testuser' }));
69+
expect(response.status).toBe(200);
70+
const body = await response.json();
71+
expect(body).toEqual({
72+
exists: true,
73+
login: 'testuser',
74+
name: 'Test User',
75+
avatar_url: 'https://github.com/testuser.png',
76+
public_repos: 12,
77+
stats: {
78+
currentStreak: 3,
79+
longestStreak: 3,
80+
totalContributions: 15,
81+
},
82+
});
83+
});
84+
85+
it('returns 404 when user is not found', async () => {
86+
vi.mocked(fetchUserProfile).mockRejectedValue(new Error('User not found'));
87+
const response = await GET(makeRequest({ username: 'missinguser' }));
88+
expect(response.status).toBe(404);
89+
const body = await response.json();
90+
expect(body.error).toBe('User not found');
91+
});
92+
93+
it('gracefully handles contributions fetch failure and returns profile details', async () => {
94+
vi.mocked(fetchGitHubContributions).mockRejectedValue(new Error('API limit reached'));
95+
const response = await GET(makeRequest({ username: 'testuser' }));
96+
expect(response.status).toBe(200);
97+
const body = await response.json();
98+
expect(body.stats).toEqual({
99+
currentStreak: 0,
100+
longestStreak: 0,
101+
totalContributions: 0,
102+
});
103+
});
104+
});

0 commit comments

Comments
 (0)