Skip to content

Commit 05266c3

Browse files
committed
feat: add cache-busting queryand X-cache-Status response headers
1 parent 5aca069 commit 05266c3

9 files changed

Lines changed: 75 additions & 35 deletions

File tree

app/api/github/route.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,12 @@ export async function GET(request: Request) {
5050
);
5151
}
5252

53-
const { username, refresh } = parseResult.data;
53+
const { username, refresh, bypassCache: bypassCacheParam } = parseResult.data;
54+
// Treat either ?refresh=true or ?bypassCache=true as a cache-bypass request
55+
const isRefreshRequested = refresh || bypassCacheParam;
5456

5557
// 1. Quota awareness check - if remaining quota is low, disable manual refresh
56-
if (refresh && quotaMonitor.isQuotaLow()) {
58+
if (isRefreshRequested && quotaMonitor.isQuotaLow()) {
5759
logSecurityEvent('LOW_QUOTA_REFRESH_BLOCKED', {
5860
username,
5961
ip,
@@ -66,7 +68,7 @@ export async function GET(request: Request) {
6668
}
6769

6870
// 2. Separate Refresh Rate Limiter
69-
if (refresh) {
71+
if (isRefreshRequested) {
7072
const rateLimitCheck = refreshRateLimiter.checkLimit(ip);
7173
if (!rateLimitCheck.success) {
7274
logSecurityEvent('REFRESH_RATE_LIMIT_EXCEEDED', {
@@ -89,8 +91,8 @@ export async function GET(request: Request) {
8991
}
9092

9193
// 3. Per-Username Refresh Cooldown
92-
let shouldBypassCache = refresh;
93-
if (refresh) {
94+
let shouldBypassCache = isRefreshRequested;
95+
if (isRefreshRequested) {
9496
if (!refreshPolicy.isRefreshAllowed(username)) {
9597
logSecurityEvent('REFRESH_COOLDOWN_VIOLATION', {
9698
username,
@@ -120,13 +122,16 @@ export async function GET(request: Request) {
120122
? 'no-cache, no-store, must-revalidate'
121123
: 's-maxage=3600, stale-while-revalidate=86400';
122124

125+
const cacheStatus = shouldBypassCache ? 'MISS' : 'HIT';
126+
123127
return NextResponse.json(data, {
124128
status: 200,
125129
headers: {
126130
'Cache-Control': cacheControl,
131+
'X-Cache-Status': cacheStatus,
127132
'X-Refresh-Status': shouldBypassCache
128133
? 'Fresh'
129-
: refresh
134+
: isRefreshRequested
130135
? 'Cooldown-Served-Cached'
131136
: 'Cached',
132137
},

app/api/og/route.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,17 @@ export async function GET(req: NextRequest) {
4949
);
5050
}
5151

52-
const { user, theme, bg, text, accent, refresh } = parseResult.data;
52+
const {
53+
user,
54+
theme,
55+
bg,
56+
text,
57+
accent,
58+
refresh,
59+
bypassCache: bypassCacheParam,
60+
} = parseResult.data;
61+
// Treat either ?refresh=true or ?bypassCache=true as a cache-bypass request
62+
const isRefreshRequested = refresh || bypassCacheParam;
5363

5464
const themeName = theme || 'dark';
5565
const isAutoTheme = themeName === 'auto';
@@ -83,7 +93,7 @@ export async function GET(req: NextRequest) {
8393
// bypassCache mirrors the ?refresh=true pattern used by /api/stats and /api/streak.
8494
// Without this, every link-preview bot crawl fires a fresh GitHub GraphQL request,
8595
// burning API quota on an endpoint that is embedded in every page's <meta> tag.
86-
const data = await fetchGitHubContributions(user, { bypassCache: refresh });
96+
const data = await fetchGitHubContributions(user, { bypassCache: isRefreshRequested });
8797
const stats = calculateStreak(data.calendar ?? data);
8898
totalCommits = stats.totalContributions;
8999
longestStreak = stats.longestStreak;
@@ -92,7 +102,7 @@ export async function GET(req: NextRequest) {
92102
console.error('[OG] stats fetch failed:', err);
93103
}
94104

95-
const cacheControl = refresh
105+
const cacheControl = isRefreshRequested
96106
? 'no-cache, no-store, must-revalidate'
97107
: 'public, max-age=3600, stale-while-revalidate=86400';
98108

app/api/stats/route.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,9 @@ export async function GET(request: Request) {
6161
);
6262
}
6363

64-
const { user, refresh, tz } = parseResult.data;
64+
const { user, refresh, bypassCache: bypassCacheParam, tz } = parseResult.data;
65+
// Treat either ?refresh=true or ?bypassCache=true as a cache-bypass request
66+
const isRefreshRequested = refresh || bypassCacheParam;
6567

6668
let timezone: string;
6769
try {
@@ -72,7 +74,7 @@ export async function GET(request: Request) {
7274
return NextResponse.json({ error: `Invalid "tz" parameter: "${tz}"` }, { status: 400 });
7375
}
7476

75-
if (refresh && quotaMonitor.isQuotaLow()) {
77+
if (isRefreshRequested && quotaMonitor.isQuotaLow()) {
7678
logSecurityEvent('LOW_QUOTA_STATS_REFRESH_BLOCKED', {
7779
user,
7880
ip,
@@ -84,7 +86,7 @@ export async function GET(request: Request) {
8486
);
8587
}
8688

87-
if (refresh) {
89+
if (isRefreshRequested) {
8890
const rateLimitCheck = refreshRateLimiter.checkLimit(ip);
8991
if (!rateLimitCheck.success) {
9092
logSecurityEvent('STATS_REFRESH_RATE_LIMIT_EXCEEDED', {
@@ -106,8 +108,8 @@ export async function GET(request: Request) {
106108
}
107109
}
108110

109-
let shouldBypassCache = refresh;
110-
if (refresh) {
111+
let shouldBypassCache = isRefreshRequested;
112+
if (isRefreshRequested) {
111113
if (!refreshPolicy.isRefreshAllowed(user)) {
112114
logSecurityEvent('STATS_REFRESH_COOLDOWN_VIOLATION', {
113115
user,
@@ -134,9 +136,10 @@ export async function GET(request: Request) {
134136
headers.set('Pragma', 'no-cache');
135137
headers.set('Expires', '0');
136138
}
139+
headers.set('X-Cache-Status', shouldBypassCache ? 'MISS' : 'HIT');
137140
headers.set(
138141
'X-Refresh-Status',
139-
shouldBypassCache ? 'Fresh' : refresh ? 'Cooldown-Served-Cached' : 'Cached'
142+
shouldBypassCache ? 'Fresh' : isRefreshRequested ? 'Cooldown-Served-Cached' : 'Cached'
140143
);
141144

142145
return NextResponse.json(

app/api/streak/route.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export async function GET(request: Request) {
7575
from: customFrom,
7676
to: customTo,
7777
refresh,
78+
bypassCache: bypassCacheParam,
7879
hide_title,
7980
hide_background,
8081
hide_stats,
@@ -105,6 +106,10 @@ export async function GET(request: Request) {
105106
const normalizedView = view as 'default' | 'monthly' | 'heatmap' | 'pulse';
106107
const themeName = theme || 'dark';
107108

109+
// Treat either ?refresh=true or ?bypassCache=true as a cache-bypass request
110+
const isRefreshRequested = refresh || bypassCacheParam;
111+
const shouldBypassCache = isRefreshRequested;
112+
108113
let timezone = 'UTC';
109114
if (tzParam) {
110115
try {
@@ -241,7 +246,7 @@ export async function GET(request: Request) {
241246
// Fetch Organization Mega-City Data OR Single User Data
242247
if (org) {
243248
const orgData = await getOrgDashboardData(org, {
244-
bypassCache: refresh,
249+
bypassCache: shouldBypassCache,
245250
from,
246251
to,
247252
});
@@ -264,7 +269,7 @@ export async function GET(request: Request) {
264269
users.map(async (u) => {
265270
try {
266271
const userData = await fetchGitHubContributions(u, {
267-
bypassCache: refresh,
272+
bypassCache: shouldBypassCache,
268273
from,
269274
to,
270275
});
@@ -290,7 +295,7 @@ export async function GET(request: Request) {
290295
}
291296
} else {
292297
const userData = await fetchGitHubContributions(user, {
293-
bypassCache: refresh,
298+
bypassCache: shouldBypassCache,
294299
from,
295300
to,
296301
});
@@ -301,7 +306,7 @@ export async function GET(request: Request) {
301306

302307
if (versus) {
303308
const versusData = await fetchGitHubContributions(versus, {
304-
bypassCache: refresh,
309+
bypassCache: shouldBypassCache,
305310
from,
306311
to,
307312
});
@@ -339,10 +344,14 @@ export async function GET(request: Request) {
339344
const secondsToMidnight = tzParam
340345
? getSecondsUntilMidnightInTimezone(timezone)
341346
: getSecondsUntilUTCMidnight();
342-
const cacheControl = refresh
347+
const cacheControl = isRefreshRequested
343348
? 'no-cache, no-store, must-revalidate'
344349
: `public, s-maxage=${secondsToMidnight}, stale-while-revalidate=86400`;
345350

351+
const cacheStatusHeader = shouldBypassCache
352+
? `BYPASS, fetched=${new Date().toISOString()}`
353+
: 'HIT';
354+
346355
const jsonPayload = JSON.stringify({
347356
user: targetEntity,
348357
stats,
@@ -372,7 +381,7 @@ export async function GET(request: Request) {
372381
'Content-Type': 'application/json',
373382
'Cache-Control': cacheControl,
374383
ETag: weakEtag,
375-
'X-Cache-Status': refresh ? `BYPASS, fetched=${new Date().toISOString()}` : 'HIT',
384+
'X-Cache-Status': cacheStatusHeader,
376385
},
377386
});
378387
}
@@ -406,7 +415,7 @@ export async function GET(request: Request) {
406415
const secondsToMidnight = tzParam
407416
? getSecondsUntilMidnightInTimezone(timezone)
408417
: getSecondsUntilUTCMidnight();
409-
const cacheControl = refresh
418+
const cacheControl = isRefreshRequested
410419
? 'no-cache, no-store, must-revalidate'
411420
: isHistoricalYear
412421
? 'public, s-maxage=31536000, immutable'
@@ -432,7 +441,7 @@ export async function GET(request: Request) {
432441
'Cache-Control': cacheControl,
433442
'Content-Security-Policy': SVG_CSP_HEADER,
434443
ETag: weakEtag,
435-
'X-Cache-Status': refresh ? `BYPASS, fetched=${new Date().toISOString()}` : 'HIT',
444+
'X-Cache-Status': shouldBypassCache ? `BYPASS, fetched=${new Date().toISOString()}` : 'HIT',
436445
},
437446
});
438447
} catch (error: unknown) {

app/api/wrapped/route.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export async function GET(request: Request) {
4848
font,
4949
year: customYear,
5050
refresh,
51+
bypassCache: bypassCacheParam,
5152
hide_title,
5253
hide_background,
5354
width,
@@ -90,14 +91,17 @@ export async function GET(request: Request) {
9091
scale: 'linear',
9192
};
9293

94+
// Treat either ?refresh=true or ?bypassCache=true as a cache-bypass request
95+
const isRefreshRequested = refresh || bypassCacheParam;
96+
9397
// Fetch the wrapped stats for the year (calendar is included to avoid a duplicate API call)
94-
const wrappedStats = await getWrappedData(user, year, { bypassCache: refresh });
98+
const wrappedStats = await getWrappedData(user, year, { bypassCache: isRefreshRequested });
9599

96100
const svg = generateWrappedSVG(wrappedStats, params, year, wrappedStats.calendar);
97101

98102
// Cache-Control: Annual wrapped stats are stable, cache for 24 hours.
99-
// Clients can bust with ?refresh=true.
100-
const cacheControl = refresh
103+
// Clients can bust with ?refresh=true or ?bypassCache=true.
104+
const cacheControl = isRefreshRequested
101105
? 'no-cache, no-store, must-revalidate'
102106
: 'public, s-maxage=86400, stale-while-revalidate=86400';
103107

@@ -106,7 +110,9 @@ export async function GET(request: Request) {
106110
'Content-Type': 'image/svg+xml',
107111
'Cache-Control': cacheControl,
108112
'Content-Security-Policy': SVG_CSP_HEADER,
109-
'X-Cache-Status': refresh ? `BYPASS, fetched=${new Date().toISOString()}` : 'HIT',
113+
'X-Cache-Status': isRefreshRequested
114+
? `BYPASS, fetched=${new Date().toISOString()}`
115+
: 'HIT',
110116
},
111117
});
112118
} catch (error: unknown) {

lib/validations.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ const baseStreakParamsSchema = z.object({
264264
{ message: 'Invalid "date" format. Use ISO 8601.' }
265265
),
266266
refresh: z.string().optional().transform(toRefreshFlag),
267+
bypassCache: z.string().optional().transform(toRefreshFlag),
267268
hide_title: z.string().optional().transform(toBooleanFlag),
268269
hide_background: z.string().optional().transform(toBooleanFlag),
269270
hide_stats: z.string().optional().transform(toBooleanFlag),
@@ -383,6 +384,7 @@ export const githubParamsSchema = z.object({
383384
message: 'Invalid GitHub username',
384385
}),
385386
refresh: z.string().optional().transform(toRefreshFlag),
387+
bypassCache: z.string().optional().transform(toRefreshFlag),
386388
});
387389

388390
export const compareParamsSchema = z
@@ -438,6 +440,7 @@ export const ogParamsSchema = z
438440
.transform(toEmptyStringAsUndefined)
439441
.transform(toValidHexColor('000000')),
440442
refresh: z.string().optional().transform(toRefreshFlag),
443+
bypassCache: z.string().optional().transform(toRefreshFlag),
441444
})
442445
.transform((data) => ({
443446
...data,
@@ -453,6 +456,7 @@ export const statsParamsSchema = z.object({
453456
message: 'Invalid GitHub username',
454457
}),
455458
refresh: z.string().optional().transform(toRefreshFlag),
459+
bypassCache: z.string().optional().transform(toRefreshFlag),
456460
tz: timeZoneParam,
457461
});
458462

@@ -534,6 +538,7 @@ export const wrappedParamsSchema = z.object({
534538
.optional()
535539
.transform((val) => sanitizeFont(val) || undefined),
536540
refresh: z.string().optional().transform(toRefreshFlag),
541+
bypassCache: z.string().optional().transform(toRefreshFlag),
537542
hide_title: z.string().optional().transform(toBooleanFlag),
538543
hide_background: z.string().optional().transform(toBooleanFlag), // ✅ Fixed: was toRefreshFlag
539544
width: dimensionParam('width', 100, 1200),

middleware.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ export async function middleware(request: NextRequest) {
2828
// Secure client IP extraction
2929
const ip = getClientIp(request);
3030

31-
const isRefresh = request.nextUrl.searchParams.get('refresh') === 'true';
31+
const isRefresh =
32+
request.nextUrl.searchParams.get('refresh') === 'true' ||
33+
request.nextUrl.searchParams.get('bypassCache') === 'true';
3234

3335
if (isRefresh) {
3436
// Stricter limit: 5 cache-bypass requests per minute per IP.

services/github/refresh-policy.empty-fallback.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,11 @@ describe('RefreshPolicy - Edge Cases & Empty/Missing Inputs', () => {
7979
expect(policy.isRefreshAllowed('user-a')).toBe(true);
8080
expect(policy.getRemainingCooldown('user-a')).toBe(0);
8181

82-
// Verify cooldown is restored to default (5 * 60 * 1000 = 300000ms)
82+
// Verify cooldown is restored to default (30 * 1000 = 30000ms)
8383
policy.recordRefresh('user-a');
84-
// It should be around 300000 right after recording (allow minor execution delay)
84+
// It should be around 30000 right after recording (allow minor execution delay)
8585
const remaining = policy.getRemainingCooldown('user-a');
86-
expect(remaining).toBeLessThanOrEqual(300000);
87-
expect(remaining).toBeGreaterThanOrEqual(299000);
86+
expect(remaining).toBeLessThanOrEqual(30000);
87+
expect(remaining).toBeGreaterThanOrEqual(29000);
8888
});
8989
});

services/github/refresh-policy.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { TTLCache } from '../../lib/cache';
44
export class RefreshPolicy {
55
private static instance: RefreshPolicy;
66

7-
// Cooldown in milliseconds (default 5 minutes)
8-
private cooldownMs = 5 * 60 * 1000;
7+
// Cooldown in milliseconds (default 30 seconds)
8+
private cooldownMs = 30 * 1000;
99

1010
// Cache of username -> last successful refresh timestamp (15,000 capacity)
1111
private refreshTimes = new TTLCache<number>(15000, 60 * 60 * 1000);
@@ -105,7 +105,7 @@ export class RefreshPolicy {
105105
*/
106106
public reset(): void {
107107
this.refreshTimes.clear();
108-
this.cooldownMs = 5 * 60 * 1000;
108+
this.cooldownMs = 30 * 1000;
109109
}
110110
}
111111

0 commit comments

Comments
 (0)