diff --git a/app/api/streak/route.ts b/app/api/streak/route.ts index c24bdf692..627ee3459 100644 --- a/app/api/streak/route.ts +++ b/app/api/streak/route.ts @@ -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 { @@ -39,25 +38,51 @@ function getMonthlyReferenceDate(year: string | undefined, timezone: string): Da 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 { + 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: { @@ -117,6 +142,8 @@ export async function GET(request: Request) { badges, entrance, } = parseResult.data; + + const normalizedView = view as 'default' | 'monthly' | 'heatmap' | 'pulse' | 'languages'; const normalizedView = view as | 'default' | 'monthly' @@ -134,8 +161,9 @@ export async function GET(request: Request) { 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}`); @@ -168,9 +196,9 @@ export async function GET(request: Request) { 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); @@ -209,7 +237,6 @@ export async function GET(request: Request) { return themes[theme] || themes.dark; })(); - // If 'org' is provided, we use it as the display user const targetEntity = org || (user.includes(',') @@ -219,9 +246,11 @@ export async function GET(request: Request) { .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 = ( @@ -253,12 +282,10 @@ export async function GET(request: Request) { width, height, size, - grace: Math.max( 0, Math.min(7, typeof grace === 'number' ? grace : parseInt(String(grace || 1), 10)) ), - mode, repo, org, @@ -269,12 +296,10 @@ export async function GET(request: Request) { 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, @@ -287,7 +312,6 @@ export async function GET(request: Request) { let versusCalendar; let repoContributions: RepoContribution[] = []; - // Fetch Organization Mega-City Data OR Single User Data if (org) { const orgData = await getOrgDashboardData(org, { bypassCache: shouldBypassCache, @@ -301,7 +325,6 @@ export async function GET(request: Request) { .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.' @@ -317,9 +340,7 @@ export async function GET(request: Request) { from, to, }); - if (userData.isOfflineFallback) { - hasOfflineFallback = true; - } + if (userData.isOfflineFallback) hasOfflineFallback = true; return userData; } catch (err) { lastError = err; @@ -327,20 +348,18 @@ export async function GET(request: Request) { } }) ); + 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, @@ -349,9 +368,7 @@ export async function GET(request: Request) { }); 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, { @@ -360,23 +377,30 @@ export async function GET(request: Request) { 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), }; } + const secondsToMidnight = tzParam + ? getSecondsUntilMidnightInTimezone(timezone) + : getSecondsUntilUTCMidnight(); + + const cacheControl = refresh + ? '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); @@ -407,7 +431,7 @@ export async function GET(request: Request) { }, }); - 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'); @@ -450,8 +474,6 @@ export async function GET(request: Request) { 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') { @@ -469,6 +491,7 @@ export async function GET(request: Request) { svg = generateSVG(stats, params, calendar); } + const etag = await generateETag(svg); const secondsToMidnight = tzParam ? getSecondsUntilMidnightInTimezone(timezone) : getSecondsUntilUTCMidnight(); @@ -514,21 +537,19 @@ type ParseResult = ReturnType; 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 ` ${line1}${ line2 - ? ` - ${line2}` + ? `${line2}` : '' } `; @@ -538,15 +559,16 @@ function buildErrorResponse(error: unknown, parseResult: ParseResult): NextRespo 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) @@ -554,7 +576,10 @@ function buildErrorResponse(error: unknown, parseResult: ParseResult): NextRespo : 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'; @@ -582,16 +607,14 @@ function buildErrorResponse(error: unknown, parseResult: ParseResult): NextRespo 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: { @@ -602,11 +625,8 @@ function buildErrorResponse(error: unknown, parseResult: ParseResult): NextRespo }); } - // 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: {