Skip to content
Closed
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
128 changes: 74 additions & 54 deletions app/api/streak/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
// app/api/streak/route.ts

import crypto from 'crypto';
import { NextResponse } from 'next/server';
import { fetchGitHubContributions, getOrgDashboardData } from '@/lib/github';
import {
Expand Down Expand Up @@ -39,25 +38,51 @@

const selectedYear = Number(year);
const currentYear = Number(
new Intl.DateTimeFormat('en-CA', { timeZone: timezone, year: 'numeric' }).format(new Date())
new Intl.DateTimeFormat('en-CA', {
timeZone: timezone,
year: 'numeric',
}).format(new Date())
);

return selectedYear < currentYear ? new Date(`${year}-12-15T12:00:00Z`) : undefined;
}

// Fixed ETag engine with safe fallback for test mock environments
async function generateETag(content: string): Promise<string> {
if (
typeof crypto !== 'undefined' &&
crypto.subtle &&
typeof crypto.subtle.digest === 'function'
) {
try {
const msgUint8 = new TextEncoder().encode(content);
const hashBuffer = await crypto.subtle.digest('SHA-1', msgUint8);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
} catch {
// Fall through to fallback hash if subtle crypto fails in test containers
}
}

// Safe internal quick string hash fallback if environment lacks Web Crypto
let hash = 0;
for (let i = 0; i < content.length; i++) {
const chr = content.charCodeAt(i);
hash = (hash << 5) - hash + chr;
hash |= 0;
}
return Math.abs(hash).toString(16);
}

export async function GET(request: Request) {
const { searchParams } = new URL(request.url);

const parseResult = streakParamsSchema.safeParse(Object.fromEntries(searchParams.entries()));

try {
if (!parseResult.success) {
const fieldErrors = parseResult.error.flatten();

return NextResponse.json(
{
error: 'Invalid parameters',
details: fieldErrors,
},
{ error: 'Invalid parameters', details: fieldErrors },
{
status: 400,
headers: {
Expand Down Expand Up @@ -117,7 +142,9 @@
badges,
entrance,
} = parseResult.data;

const normalizedView = view as 'default' | 'monthly' | 'heatmap' | 'pulse' | 'languages';

Check failure on line 146 in app/api/streak/route.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

Cannot redeclare block-scoped variable 'normalizedView'.
const normalizedView = view as

Check failure on line 147 in app/api/streak/route.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

Cannot redeclare block-scoped variable 'normalizedView'.
| 'default'
| 'monthly'
| 'heatmap'
Expand All @@ -134,8 +161,9 @@
let timezone = 'UTC';
if (tzParam) {
try {
timezone = new Intl.DateTimeFormat(undefined, { timeZone: tzParam }).resolvedOptions()
.timeZone;
timezone = new Intl.DateTimeFormat(undefined, {
timeZone: tzParam,
}).resolvedOptions().timeZone;
} catch (error) {
if (error instanceof RangeError) {
const validationErr = new Error(`Invalid timezone: ${tzParam}`);
Expand Down Expand Up @@ -168,9 +196,9 @@

if (normalizedView === 'monthly') {
const referenceDate = getMonthlyReferenceDate(year, timezone) || new Date();
const localTodayStr = new Intl.DateTimeFormat('en-CA', { timeZone: timezone }).format(
referenceDate
);
const localTodayStr = new Intl.DateTimeFormat('en-CA', {
timeZone: timezone,
}).format(referenceDate);
const [currentYearStr, currentMonthStr] = localTodayStr.split('-');
const currentYearNum = parseInt(currentYearStr, 10);
const currentMonthNum = parseInt(currentMonthStr, 10);
Expand Down Expand Up @@ -209,7 +237,6 @@
return themes[theme] || themes.dark;
})();

// If 'org' is provided, we use it as the display user
const targetEntity =
org ||
(user.includes(',')
Expand All @@ -219,9 +246,11 @@
.slice(0, 2)
.join(' + ')
: user);

const borderParam = searchParams.get('border');
const sanitizedBorder = borderParam ? borderParam.replace(/[^a-fA-F0-9]/g, '') : undefined;
const animate = searchParams.get('animate') !== 'false';

// Validate and clamp the speed param to prevent broken SVG animation
const rawSpeedNum = speed ? parseFloat(String(speed)) : NaN;
const validatedSpeed = (
Expand Down Expand Up @@ -253,12 +282,10 @@
width,
height,
size,

grace: Math.max(
0,
Math.min(7, typeof grace === 'number' ? grace : parseInt(String(grace || 1), 10))
),

mode,
repo,
org,
Expand All @@ -269,12 +296,10 @@
gradient,
gradient_stops,
gradient_dir,

opacity: Math.max(
0.1,
Math.min(1.0, typeof opacity === 'number' ? opacity : parseFloat(String(opacity || 1.0)))
),

disable_particles,
glow,
animate,
Expand All @@ -287,7 +312,6 @@
let versusCalendar;
let repoContributions: RepoContribution[] = [];

// Fetch Organization Mega-City Data OR Single User Data
if (org) {
const orgData = await getOrgDashboardData(org, {
bypassCache: shouldBypassCache,
Expand All @@ -301,7 +325,6 @@
.split(',')
.map((u) => u.trim())
.filter(Boolean);

if (users.length > 2) {
throw new Error(
'ValidationError: The streak comparison generator strictly accepts a maximum of 2 usernames.'
Expand All @@ -317,30 +340,26 @@
from,
to,
});
if (userData.isOfflineFallback) {
hasOfflineFallback = true;
}
if (userData.isOfflineFallback) hasOfflineFallback = true;
return userData;
} catch (err) {
lastError = err;
return null;
}
})
);

const successfulData = fetchedCalendars.filter(
(d): d is ExtendedContributionData => d !== null
);
if (successfulData.length === 0) {
throw lastError || new Error('No successful data fetched');
}
if (successfulData.length === 0) throw lastError || new Error('No successful data fetched');

calendar = aggregateCalendars(successfulData.map((d) => d.calendar));
repoContributions =
normalizedView === 'languages'
? successfulData.flatMap((d) => d.repoContributions || [])
: [];
if (hasOfflineFallback) {
params.isOfflineFallback = true;
}
if (hasOfflineFallback) params.isOfflineFallback = true;
} else {
const userData = await fetchGitHubContributions(user, {
bypassCache: shouldBypassCache,
Expand All @@ -349,9 +368,7 @@
});
calendar = userData.calendar;
repoContributions = normalizedView === 'languages' ? userData.repoContributions || [] : [];
if (userData.isOfflineFallback) {
params.isOfflineFallback = true;
}
if (userData.isOfflineFallback) params.isOfflineFallback = true;

if (versus) {
const versusData = await fetchGitHubContributions(versus, {
Expand All @@ -360,23 +377,30 @@
to,
});
versusCalendar = versusData.calendar;
if (versusData.isOfflineFallback) {
params.isOfflineFallback = true;
}
if (versusData.isOfflineFallback) params.isOfflineFallback = true;
}
}

if (days && normalizedView !== 'monthly') {
const allDays = calendar.weeks.flatMap((w) => w.contributionDays);

const filteredDays = allDays.slice(-days);

calendar = {
totalContributions: filteredDays.reduce((sum, d) => sum + d.contributionCount, 0),
weeks: [{ contributionDays: filteredDays }],
weeks: chunkDaysIntoWeeks(filteredDays),

Check failure on line 390 in app/api/streak/route.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

An object literal cannot have multiple properties with the same name.
};
}

const secondsToMidnight = tzParam

Check failure on line 394 in app/api/streak/route.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

Cannot redeclare block-scoped variable 'secondsToMidnight'.
? getSecondsUntilMidnightInTimezone(timezone)
: getSecondsUntilUTCMidnight();

const cacheControl = refresh

Check failure on line 398 in app/api/streak/route.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

Cannot redeclare block-scoped variable 'cacheControl'.
? 'no-cache, no-store, must-revalidate'
: isHistoricalYear
? 'public, s-maxage=31536000, immutable'
: `public, s-maxage=${secondsToMidnight}, stale-while-revalidate=86400`;

// ─── JSON output mode ──────────────────────────────────────────────────
if (format === 'json') {
const stats = calculateStreak(calendar, timezone, undefined, grace);
Expand Down Expand Up @@ -407,7 +431,7 @@
},
});

const etag = crypto.createHash('sha1').update(jsonPayload).digest('hex');
const etag = await generateETag(jsonPayload);
const weakEtag = `W/"${etag}"`;
const ifNoneMatch = request.headers.get('if-none-match');

Expand Down Expand Up @@ -450,14 +474,12 @@
const stats = calculateStreak(calendar, timezone, undefined, grace);
svg = generateHeatmapSVG(stats, params, calendar);
} else if (normalizedView === 'pulse') {
// We still use calculateStreak here to efficiently parse totalContributions for the stat display,
// even though the sparkline generator will extract its own daily 30-day timeline below.
const stats = calculateStreak(calendar, timezone, undefined, grace);
svg = generatePulseSVG(stats, params, calendar);
} else if (normalizedView === 'skyline') {

Check failure on line 479 in app/api/streak/route.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

This comparison appears to be unintentional because the types '"default"' and '"skyline"' have no overlap.
const stats = calculateStreak(calendar, timezone, undefined, grace);
svg = generateSkylineSVG(stats, params, calendar);
} else if (normalizedView === 'constellation') {

Check failure on line 482 in app/api/streak/route.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

This comparison appears to be unintentional because the types '"default"' and '"constellation"' have no overlap.
const stats = calculateStreak(calendar, timezone, undefined, grace);
svg = generateConstellationSVG(stats, params, calendar);
} else if (versus && versusCalendar) {
Expand All @@ -469,10 +491,11 @@
svg = generateSVG(stats, params, calendar);
}

const etag = await generateETag(svg);

Check failure on line 494 in app/api/streak/route.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

Cannot redeclare block-scoped variable 'etag'.
const secondsToMidnight = tzParam

Check failure on line 495 in app/api/streak/route.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

Cannot redeclare block-scoped variable 'secondsToMidnight'.
? getSecondsUntilMidnightInTimezone(timezone)
: getSecondsUntilUTCMidnight();
const cacheControl = isRefreshRequested

Check failure on line 498 in app/api/streak/route.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

Cannot redeclare block-scoped variable 'cacheControl'.
? 'no-cache, no-store, must-revalidate'
: isHistoricalYear
? 'public, s-maxage=31536000, immutable'
Expand Down Expand Up @@ -514,21 +537,19 @@

function buildErrorResponse(error: unknown, parseResult: ParseResult): NextResponse {
const message = error instanceof Error ? error.message : String(error);

function buildInlineErrorSVG(text: string): string {
const MAX_LINE = 48;
const truncated = text.length > MAX_LINE * 2 ? text.slice(0, MAX_LINE * 2 - 1) + '…' : text;

const line1 = escapeSVGText(truncated.slice(0, MAX_LINE));
const line2 = truncated.length > MAX_LINE ? escapeSVGText(truncated.slice(MAX_LINE)) : null;

const textY = line2 ? '62' : '75';

return `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="150" viewBox="0 0 400 150">
<rect width="400" height="150" fill="#2d0000" rx="8"/>
<text x="200" y="${textY}" text-anchor="middle" dominant-baseline="central" fill="#ffcccc" font-family="sans-serif" font-size="13">${line1}</text>${
line2
? `
<text x="200" y="91" text-anchor="middle" dominant-baseline="central" fill="#ffcccc" font-family="sans-serif" font-size="13">${line2}</text>`
? `<text x="200" y="91" text-anchor="middle" dominant-baseline="central" fill="#ffcccc" font-family="sans-serif" font-size="13">${line2}</text>`
: ''
}
</svg>`;
Expand All @@ -538,23 +559,27 @@
message.toLowerCase().includes('not found') ||
message.toLowerCase().includes('could not resolve');
const isRateLimit = message.toLowerCase().includes('rate limit');

// 2. Safely detect if the error was a validation/client error
const isValidationError =
(error instanceof Error && error.name === 'ValidationError') ||
message.toLowerCase().includes('invalid') ||
message.toLowerCase().includes('validation') ||
message.toLowerCase().includes('strictly for organizations');

const errBg = `#${sanitizeHexColor(parseResult.success ? parseResult.data.bg : undefined, '0d1117')}`;
const errBg = `#${sanitizeHexColor(
parseResult.success ? parseResult.data.bg : undefined,
'0d1117'
)}`;
const errAccentRaw =
(parseResult.success &&
(Array.isArray(parseResult.data.accent)
? parseResult.data.accent[parseResult.data.accent.length - 1]
: parseResult.data.accent)) ||
undefined;
const errAccent = `#${sanitizeHexColor(errAccentRaw, '58a6ff')}`;
const errText = `#${sanitizeHexColor(parseResult.success ? parseResult.data.text : undefined, 'c9d1d9')}`;
const errText = `#${sanitizeHexColor(
parseResult.success ? parseResult.data.text : undefined,
'c9d1d9'
)}`;
const errRadius = sanitizeRadius(parseResult.success ? parseResult.data.radius : undefined, 8);
const errSpeed = (parseResult.success && parseResult.data.speed) || '8s';

Expand Down Expand Up @@ -582,16 +607,14 @@
status: 404,
headers: {
'Content-Type': 'image/svg+xml',
'Cache-Control': 'no-cache',
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=7200, must-revalidate',
'Content-Security-Policy': SVG_CSP_HEADER,
},
});
}

// 3. Return a 400 Bad Request for Validation Errors
if (isValidationError) {
const validationSvg = buildInlineErrorSVG(message);

return new NextResponse(validationSvg, {
status: 400,
headers: {
Expand All @@ -602,11 +625,8 @@
});
}

// 4. Return a 500 Internal Server Error for real crashes
console.error('[streak] Unhandled error:', message);

const errorSvg = buildInlineErrorSVG('Something went wrong. Please try again later.');

return new NextResponse(errorSvg, {
status: 500,
headers: {
Expand Down
Loading