-
Notifications
You must be signed in to change notification settings - Fork 662
Expand file tree
/
Copy pathroute.ts
More file actions
249 lines (217 loc) · 7.79 KB
/
Copy pathroute.ts
File metadata and controls
249 lines (217 loc) · 7.79 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
// app/api/github/route.ts
import { NextResponse, after } from 'next/server';
import { getFullDashboardData, isAbortError } from '@/lib/github';
import { githubParamsSchema, coerceQueryParams } from '@/lib/validations';
import { getClientIp } from '@/utils/getClientIp';
import { quotaMonitor } from '@/services/github/quota-monitor';
import { refreshPolicy } from '@/services/github/refresh-policy';
import { getRateLimitHeaders } from '@/lib/rate-limit';
import { refreshRateLimiter } from '@/services/github/refresh-rate-limiter';
import { backgroundRefresh } from '@/services/github/background-refresh';
import { logger } from '@/lib/logger';
import { RateLimiter } from '@/lib/rate-limit';
const dashboardLimiter = new RateLimiter(10, 60_000, 1);
const MAX_ERROR_CAUSE_DEPTH = 10;
function getSafeRootCause(error: unknown): unknown {
let currentErr: unknown = error;
const visitedErrors = new WeakSet<object>();
let depth = 0;
while (
currentErr &&
typeof currentErr === 'object' &&
'cause' in currentErr &&
depth < MAX_ERROR_CAUSE_DEPTH
) {
if (visitedErrors.has(currentErr)) {
return currentErr;
}
visitedErrors.add(currentErr);
currentErr = (currentErr as { cause?: unknown }).cause;
depth += 1;
}
return currentErr;
}
function logSecurityEvent(event: string, details: Record<string, unknown>) {
logger.warn('Security event', {
type: 'SECURITY_EVENT',
event,
...details,
});
}
/**
* Returns GitHub dashboard data as JSON.
*
* Query params:
* - username: GitHub username to fetch dashboard statistics for
* - refresh: Optional boolean to bypass cache and fetch fresh data
*
* Success (200):
* - Returns dashboard profile, repositories, activity and contribution data
*
* Error codes:
* - 400 → Invalid query parameters
* - 403 → GitHub API rate limit reached
* - 404 → GitHub user not found
* - 429 → Too many requests (Refresh rate limit or low quota)
* - 500 → Internal server error
*/
export async function GET(request: Request) {
const ip = getClientIp(request);
const rateLimitKey =
ip && ip !== 'unknown' ? ip : `unknown:${request.headers.get('user-agent') ?? 'no-agent'}`;
if (!(await dashboardLimiter.check(rateLimitKey))) {
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{ status: 429 }
);
}
const { searchParams } = new URL(request.url);
const parseResult = githubParamsSchema.safeParse(coerceQueryParams(searchParams));
if (!parseResult.success) {
return NextResponse.json(
{ error: 'Invalid parameters', details: parseResult.error.flatten() },
{ status: 400 }
);
}
const { username, refresh, bypassCache: bypassCacheParam, org, excludeBots } = parseResult.data;
// Treat either ?refresh=true or ?bypassCache=true as a cache-bypass request
const isRefreshRequested = refresh || bypassCacheParam;
// 1. Quota awareness check - if remaining quota is low, disable manual refresh
if (isRefreshRequested && quotaMonitor.isQuotaLow()) {
logSecurityEvent('LOW_QUOTA_REFRESH_BLOCKED', {
username,
ip,
remainingQuota: quotaMonitor.getQuota().remaining,
});
return NextResponse.json(
{ error: 'GitHub API quota is low. Cache refresh temporarily disabled.' },
{ status: 429, headers: { 'Retry-After': '60' } }
);
}
// 2. Separate Refresh Rate Limiter
if (isRefreshRequested) {
const rateLimitCheck = refreshRateLimiter.checkLimit(ip);
if (!rateLimitCheck.success) {
logSecurityEvent('REFRESH_RATE_LIMIT_EXCEEDED', {
username,
ip,
limit: rateLimitCheck.limit,
});
return NextResponse.json(
{ error: 'Refresh rate limit exceeded. Please try again later.' },
{
status: 429,
headers: getRateLimitHeaders(rateLimitCheck),
}
);
}
}
// 3. Per-Username Refresh Cooldown
let shouldBypassCache = isRefreshRequested;
if (isRefreshRequested) {
if (!refreshPolicy.isRefreshAllowed(username)) {
logSecurityEvent('REFRESH_COOLDOWN_VIOLATION', {
username,
ip,
remainingMs: refreshPolicy.getRemainingCooldown(username),
});
// Fallback: serve cached data instead of bypassing cache
shouldBypassCache = false;
} else {
refreshPolicy.recordRefresh(username);
}
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
try {
const data = await getFullDashboardData(username, {
bypassCache: shouldBypassCache,
signal: controller.signal,
org,
excludeBots,
});
// 4. Stale-While-Revalidate background refresh for normal cached requests
if (!shouldBypassCache) {
const lastSynced = data.lastSyncedAt;
if (backgroundRefresh.isStale(lastSynced)) {
// Run after the response is sent so Vercel does not freeze the function mid-refresh.
after(() => backgroundRefresh.triggerRefresh(username));
}
}
const cacheControl = shouldBypassCache
? 'no-cache, no-store, must-revalidate'
: 's-maxage=1, stale-while-revalidate=59';
const cacheStatus = shouldBypassCache ? 'MISS' : 'HIT';
return NextResponse.json(data, {
status: 200,
headers: {
'Cache-Control': cacheControl,
'X-Cache-Status': cacheStatus,
'X-Refresh-Status': shouldBypassCache
? 'Fresh'
: isRefreshRequested
? 'Cooldown-Served-Cached'
: 'Cached',
},
});
} catch (error: unknown) {
const rootCause = getSafeRootCause(error);
const err = (rootCause || error) as {
status?: number;
response?: { status?: number };
message?: string;
};
const status = err.status || err.response?.status || undefined;
const message = err.message || '';
// 404 - User not found (status-first; exact message match as fallback for GraphQL paths
// that throw without an HTTP status, e.g. `new Error('User not found')` in lib/github.ts)
if (status === 404 || message === 'User not found') {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// 401 - Invalid or missing token
if (status === 401) {
return NextResponse.json(
{ error: 'GitHub token is invalid or missing. Please configure GITHUB_TOKEN.' },
{ status: 401 }
);
}
// 403 - Forbidden / rate limit exhausted (x-ratelimit-remaining: 0)
if (status === 403) {
return NextResponse.json(
{ error: 'GitHub API rate limit reached. Please configure GITHUB_TOKEN.' },
{ status: 403 }
);
}
// 429 - Too many requests
if (status === 429) {
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{ status: 429, headers: { 'Retry-After': '60' } }
);
}
// Fallback for GraphQL-level rate limit errors that arrive with HTTP 200
// (lib/github.ts throws `new Error('API Rate Limit Exceeded')` in this case).
if (!status && message === 'API Rate Limit Exceeded') {
return NextResponse.json(
{ error: 'GitHub API rate limit reached. Please configure GITHUB_TOKEN.' },
{ status: 403 }
);
}
// 504 - Upstream request timeout or AbortController abort
if (isAbortError(rootCause || error)) {
return NextResponse.json(
{ error: 'Upstream request timed out after 10 seconds.' },
{ status: 504 }
);
}
// Default fallback — log full detail server-side; never forward raw error
// strings to callers (fixes: information-leak via unhandled 500 responses).
logger.error('Unhandled error in GET /api/github', { error });
return NextResponse.json(
{ error: 'An unexpected error occurred. Please try again.' },
{ status: 500 }
);
} finally {
clearTimeout(timeoutId);
}
}