Skip to content
Merged
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
17 changes: 11 additions & 6 deletions app/api/github/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,12 @@ export async function GET(request: Request) {
);
}

const { username, refresh } = parseResult.data;
const { username, refresh, bypassCache: bypassCacheParam } = 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 (refresh && quotaMonitor.isQuotaLow()) {
if (isRefreshRequested && quotaMonitor.isQuotaLow()) {
logSecurityEvent('LOW_QUOTA_REFRESH_BLOCKED', {
username,
ip,
Expand All @@ -66,7 +68,7 @@ export async function GET(request: Request) {
}

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

// 3. Per-Username Refresh Cooldown
let shouldBypassCache = refresh;
if (refresh) {
let shouldBypassCache = isRefreshRequested;
if (isRefreshRequested) {
if (!refreshPolicy.isRefreshAllowed(username)) {
logSecurityEvent('REFRESH_COOLDOWN_VIOLATION', {
username,
Expand Down Expand Up @@ -120,13 +122,16 @@ export async function GET(request: Request) {
? 'no-cache, no-store, must-revalidate'
: 's-maxage=3600, stale-while-revalidate=86400';

const cacheStatus = shouldBypassCache ? 'MISS' : 'HIT';

return NextResponse.json(data, {
status: 200,
headers: {
'Cache-Control': cacheControl,
'X-Cache-Status': cacheStatus,
'X-Refresh-Status': shouldBypassCache
? 'Fresh'
: refresh
: isRefreshRequested
? 'Cooldown-Served-Cached'
: 'Cached',
},
Expand Down
16 changes: 13 additions & 3 deletions app/api/og/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,17 @@ export async function GET(req: NextRequest) {
);
}

const { user, theme, bg, text, accent, refresh } = parseResult.data;
const {
user,
theme,
bg,
text,
accent,
refresh,
bypassCache: bypassCacheParam,
} = parseResult.data;
// Treat either ?refresh=true or ?bypassCache=true as a cache-bypass request
const isRefreshRequested = refresh || bypassCacheParam;

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

const cacheControl = refresh
const cacheControl = isRefreshRequested
? 'no-cache, no-store, must-revalidate'
: 'public, max-age=3600, stale-while-revalidate=86400';

Expand Down
15 changes: 9 additions & 6 deletions app/api/stats/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ export async function GET(request: Request) {
);
}

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

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

if (refresh && quotaMonitor.isQuotaLow()) {
if (isRefreshRequested && quotaMonitor.isQuotaLow()) {
logSecurityEvent('LOW_QUOTA_STATS_REFRESH_BLOCKED', {
user,
ip,
Expand All @@ -84,7 +86,7 @@ export async function GET(request: Request) {
);
}

if (refresh) {
if (isRefreshRequested) {
const rateLimitCheck = refreshRateLimiter.checkLimit(ip);
if (!rateLimitCheck.success) {
logSecurityEvent('STATS_REFRESH_RATE_LIMIT_EXCEEDED', {
Expand All @@ -106,8 +108,8 @@ export async function GET(request: Request) {
}
}

let shouldBypassCache = refresh;
if (refresh) {
let shouldBypassCache = isRefreshRequested;
if (isRefreshRequested) {
if (!refreshPolicy.isRefreshAllowed(user)) {
logSecurityEvent('STATS_REFRESH_COOLDOWN_VIOLATION', {
user,
Expand All @@ -134,9 +136,10 @@ export async function GET(request: Request) {
headers.set('Pragma', 'no-cache');
headers.set('Expires', '0');
}
headers.set('X-Cache-Status', shouldBypassCache ? 'MISS' : 'HIT');
headers.set(
'X-Refresh-Status',
shouldBypassCache ? 'Fresh' : refresh ? 'Cooldown-Served-Cached' : 'Cached'
shouldBypassCache ? 'Fresh' : isRefreshRequested ? 'Cooldown-Served-Cached' : 'Cached'
);

return NextResponse.json(
Expand Down
25 changes: 17 additions & 8 deletions app/api/streak/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import { getSecondsUntilUTCMidnight, getSecondsUntilMidnightInTimezone } from '@/utils/time';
import type {
BadgeParams,
ContributionCalendar,

Check warning on line 20 in app/api/streak/route.ts

View workflow job for this annotation

GitHub Actions / Format Β· Lint Β· Typecheck Β· Test

'ContributionCalendar' is defined but never used
RepoContribution,
ExtendedContributionData,
} from '@/types';
Expand Down Expand Up @@ -81,6 +81,7 @@
from: customFrom,
to: customTo,
refresh,
bypassCache: bypassCacheParam,
hide_title,
hide_background,
hide_stats,
Expand Down Expand Up @@ -113,6 +114,10 @@
const normalizedView = view as 'default' | 'monthly' | 'heatmap' | 'pulse' | 'languages';
const themeName = theme || 'dark';

// Treat either ?refresh=true or ?bypassCache=true as a cache-bypass request
const isRefreshRequested = refresh || bypassCacheParam;
const shouldBypassCache = isRefreshRequested;

let timezone = 'UTC';
if (tzParam) {
try {
Expand Down Expand Up @@ -252,7 +257,7 @@
// Fetch Organization Mega-City Data OR Single User Data
if (org) {
const orgData = await getOrgDashboardData(org, {
bypassCache: refresh,
bypassCache: shouldBypassCache,
from,
to,
});
Expand All @@ -276,7 +281,7 @@
users.map(async (u) => {
try {
const userData = await fetchGitHubContributions(u, {
bypassCache: refresh,
bypassCache: shouldBypassCache,
from,
to,
});
Expand Down Expand Up @@ -306,7 +311,7 @@
}
} else {
const userData = await fetchGitHubContributions(user, {
bypassCache: refresh,
bypassCache: shouldBypassCache,
from,
to,
});
Expand All @@ -318,7 +323,7 @@

if (versus) {
const versusData = await fetchGitHubContributions(versus, {
bypassCache: refresh,
bypassCache: shouldBypassCache,
from,
to,
});
Expand Down Expand Up @@ -356,10 +361,14 @@
const secondsToMidnight = tzParam
? getSecondsUntilMidnightInTimezone(timezone)
: getSecondsUntilUTCMidnight();
const cacheControl = refresh
const cacheControl = isRefreshRequested
? 'no-cache, no-store, must-revalidate'
: `public, s-maxage=${secondsToMidnight}, stale-while-revalidate=86400`;

const cacheStatusHeader = shouldBypassCache
? `BYPASS, fetched=${new Date().toISOString()}`
: 'HIT';

const jsonPayload = JSON.stringify({
user: targetEntity,
stats,
Expand Down Expand Up @@ -392,7 +401,7 @@
'Content-Type': 'application/json',
'Cache-Control': cacheControl,
ETag: weakEtag,
'X-Cache-Status': refresh ? `BYPASS, fetched=${new Date().toISOString()}` : 'HIT',
'X-Cache-Status': cacheStatusHeader,
},
});
}
Expand Down Expand Up @@ -429,7 +438,7 @@
const secondsToMidnight = tzParam
? getSecondsUntilMidnightInTimezone(timezone)
: getSecondsUntilUTCMidnight();
const cacheControl = refresh
const cacheControl = isRefreshRequested
? 'no-cache, no-store, must-revalidate'
: isHistoricalYear
? 'public, s-maxage=31536000, immutable'
Expand Down Expand Up @@ -458,7 +467,7 @@
'Cache-Control': cacheControl,
'Content-Security-Policy': SVG_CSP_HEADER,
ETag: weakEtag,
'X-Cache-Status': refresh ? `BYPASS, fetched=${new Date().toISOString()}` : 'HIT',
'X-Cache-Status': shouldBypassCache ? `BYPASS, fetched=${new Date().toISOString()}` : 'HIT',
},
});
} catch (error: unknown) {
Expand Down
14 changes: 10 additions & 4 deletions app/api/wrapped/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export async function GET(request: Request) {
font,
year: customYear,
refresh,
bypassCache: bypassCacheParam,
hide_title,
hide_background,
width,
Expand Down Expand Up @@ -90,14 +91,17 @@ export async function GET(request: Request) {
scale: 'linear',
};

// Treat either ?refresh=true or ?bypassCache=true as a cache-bypass request
const isRefreshRequested = refresh || bypassCacheParam;

// Fetch the wrapped stats for the year (calendar is included to avoid a duplicate API call)
const wrappedStats = await getWrappedData(user, year, { bypassCache: refresh });
const wrappedStats = await getWrappedData(user, year, { bypassCache: isRefreshRequested });

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

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

Expand All @@ -106,7 +110,9 @@ export async function GET(request: Request) {
'Content-Type': 'image/svg+xml',
'Cache-Control': cacheControl,
'Content-Security-Policy': SVG_CSP_HEADER,
'X-Cache-Status': refresh ? `BYPASS, fetched=${new Date().toISOString()}` : 'HIT',
'X-Cache-Status': isRefreshRequested
? `BYPASS, fetched=${new Date().toISOString()}`
: 'HIT',
},
});
} catch (error: unknown) {
Expand Down
7 changes: 6 additions & 1 deletion app/components/CopyRepoButton.massive-scaling.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ describe('CopyRepoButton Massive Scaling', () => {
});

expect(screen.queryByText('Copied!')).toBeNull();
expect(screen.getByText('Copy failed')).toBeDefined();
// Use a function matcher to handle text split across multiple elements
expect(
screen.getAllByText((content, element) => {
return element?.textContent?.includes('Copy failed') ?? false;
})[0]
).toBeDefined();
});
});
5 changes: 5 additions & 0 deletions lib/validations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ const baseStreakParamsSchema = z.object({
{ message: 'Invalid "date" format. Use ISO 8601.' }
),
refresh: z.string().optional().transform(toRefreshFlag),
bypassCache: z.string().optional().transform(toRefreshFlag),
hide_title: z.string().optional().transform(toBooleanFlag),
hide_background: z.string().optional().transform(toBooleanFlag),
hide_stats: z.string().optional().transform(toBooleanFlag),
Expand Down Expand Up @@ -396,6 +397,7 @@ export const githubParamsSchema = z.object({
message: 'Invalid GitHub username',
}),
refresh: z.string().optional().transform(toRefreshFlag),
bypassCache: z.string().optional().transform(toRefreshFlag),
});

export const compareParamsSchema = z
Expand Down Expand Up @@ -451,6 +453,7 @@ export const ogParamsSchema = z
.transform(toEmptyStringAsUndefined)
.transform(toValidHexColor('000000')),
refresh: z.string().optional().transform(toRefreshFlag),
bypassCache: z.string().optional().transform(toRefreshFlag),
})
.transform((data) => ({
...data,
Expand All @@ -466,6 +469,7 @@ export const statsParamsSchema = z.object({
message: 'Invalid GitHub username',
}),
refresh: z.string().optional().transform(toRefreshFlag),
bypassCache: z.string().optional().transform(toRefreshFlag),
tz: timeZoneParam,
});

Expand Down Expand Up @@ -547,6 +551,7 @@ export const wrappedParamsSchema = z.object({
.optional()
.transform((val) => sanitizeFont(val) || undefined),
refresh: z.string().optional().transform(toRefreshFlag),
bypassCache: z.string().optional().transform(toRefreshFlag),
hide_title: z.string().optional().transform(toBooleanFlag),
hide_background: z.string().optional().transform(toBooleanFlag), // βœ… Fixed: was toRefreshFlag
width: dimensionParam('width', 100, 1200),
Expand Down
4 changes: 3 additions & 1 deletion proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import { getClientIp } from './utils/getClientIp';
export async function proxy(request: NextRequest): Promise<NextResponse> {
const ip = getClientIp(request);

const isRefresh = request.nextUrl.searchParams.get('refresh') === 'true';
const isRefresh =
request.nextUrl.searchParams.get('refresh') === 'true' ||
request.nextUrl.searchParams.get('bypassCache') === 'true';

if (isRefresh) {
const refreshResult = await rateLimit(`refresh:${ip}`, 5, 60000);
Expand Down
8 changes: 4 additions & 4 deletions services/github/refresh-policy.empty-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,11 @@ describe('RefreshPolicy - Edge Cases & Empty/Missing Inputs', () => {
expect(policy.isRefreshAllowed('user-a')).toBe(true);
expect(policy.getRemainingCooldown('user-a')).toBe(0);

// Verify cooldown is restored to default (5 * 60 * 1000 = 300000ms)
// Verify cooldown is restored to default (30 * 1000 = 30000ms)
policy.recordRefresh('user-a');
// It should be around 300000 right after recording (allow minor execution delay)
// It should be around 30000 right after recording (allow minor execution delay)
const remaining = policy.getRemainingCooldown('user-a');
expect(remaining).toBeLessThanOrEqual(300000);
expect(remaining).toBeGreaterThanOrEqual(299000);
expect(remaining).toBeLessThanOrEqual(30000);
expect(remaining).toBeGreaterThanOrEqual(29000);
});
});
6 changes: 3 additions & 3 deletions services/github/refresh-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { TTLCache } from '../../lib/cache';
export class RefreshPolicy {
private static instance: RefreshPolicy;

// Cooldown in milliseconds (default 5 minutes)
private cooldownMs = 5 * 60 * 1000;
// Cooldown in milliseconds (default 30 seconds)
private cooldownMs = 30 * 1000;

// Cache of username -> last successful refresh timestamp (15,000 capacity)
private refreshTimes = new TTLCache<number>(15000, 60 * 60 * 1000);
Expand Down Expand Up @@ -105,7 +105,7 @@ export class RefreshPolicy {
*/
public reset(): void {
this.refreshTimes.clear();
this.cooldownMs = 5 * 60 * 1000;
this.cooldownMs = 30 * 1000;
}
}

Expand Down
Loading