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
13 changes: 9 additions & 4 deletions app/api/spotify/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import { NextResponse } from 'next/server';
import { getCurrentlyPlaying } from '@/services/spotify/api';
import { generateSpotifySVG } from '@/lib/svg/spotify';
import { buildInlineErrorSVG } from '@/lib/svg/generator';
import { resolveErrorTheme } from '@/lib/svg/themes';
import { spotifyParamsSchema, coerceQueryParams } from '@/lib/validations';
import { optimizeSVG } from '@/lib/svg/optimizer';
import crypto from 'crypto';
Expand Down Expand Up @@ -39,10 +41,13 @@ export async function GET(request: Request) {
fieldErrors.formErrors[0] ??
'Invalid parameters';

const errorSvg = `<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="75" text-anchor="middle" dominant-baseline="central" fill="#ffcccc" font-family="sans-serif" font-size="13">${firstError}</text>
</svg>`;
const errTheme = resolveErrorTheme(searchParams);
const errorSvg = buildInlineErrorSVG(firstError, {
bg: errTheme.bg,
accent: errTheme.accent,
text: errTheme.text,
radius: errTheme.radius,
});

return new NextResponse(errorSvg, {
status: 400,
Expand Down
126 changes: 76 additions & 50 deletions app/api/spotlight/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,74 +2,90 @@
import { NextResponse } from 'next/server';
import { fetchRepoDetails } from '@/lib/github';
import { generateRepoSpotlightSVG } from '@/lib/svg/repoSpotlight';
import { getNormalizedThemeKey, themes } from '@/lib/svg/themes';
import { getNormalizedThemeKey, themes, resolveErrorTheme } from '@/lib/svg/themes';
import { buildInlineErrorSVG } from '@/lib/svg/generator';
import { streakParamsSchema, coerceQueryParams } from '@/lib/validations';
import { getClientIp } from '@/utils/getClientIp';
import { RateLimiter, getRateLimitHeaders } from '@/lib/rate-limit';
import { escapeXML } from '@/lib/svg/sanitizer';

Check warning on line 10 in app/api/spotlight/route.ts

View workflow job for this annotation

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

'escapeXML' is defined but never used

const spotlightLimiter = new RateLimiter(50, 60_000, 1);

const SVG_CSP_HEADER =
"default-src 'none'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; connect-src https://fonts.gstatic.com;";

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 = escapeXML(truncated.slice(0, MAX_LINE));
const line2 = truncated.length > MAX_LINE ? escapeXML(truncated.slice(MAX_LINE)) : null;
const textY = line2 ? '62' : '75';
return `<svg xmlns="http://www.w3.org/2000/svg" width="450" height="160" viewBox="0 0 450 160">
<rect width="450" height="160" fill="#2d0000" rx="8"/>
<text x="225" y="${textY}" text-anchor="middle" dominant-baseline="central" fill="#ffcccc" font-family="sans-serif" font-size="13">${line1}</text>${
line2
? `\n <text x="225" y="91" text-anchor="middle" dominant-baseline="central" fill="#ffcccc" font-family="sans-serif" font-size="13">${line2}</text>`
: ''
}
</svg>`;
}

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

const ip = getClientIp(request);
const rateLimitKey =
ip && ip !== 'unknown' ? ip : `unknown:${request.headers.get('user-agent') ?? 'no-agent'}`;

const rateLimitResult = await spotlightLimiter.checkWithResult(rateLimitKey);
if (!rateLimitResult.success) {
return new NextResponse(buildInlineErrorSVG('Rate Limit Exceeded'), {
status: 429,
headers: {
'Content-Type': 'image/svg+xml; charset=utf-8',
'Content-Security-Policy': SVG_CSP_HEADER,
'Cache-Control': 'no-store',
...getRateLimitHeaders(rateLimitResult),
},
});
return new NextResponse(
buildInlineErrorSVG('Rate Limit Exceeded', {
bg: errTheme.bg,
accent: errTheme.accent,
text: errTheme.text,
radius: errTheme.radius,
width: 450,
height: 160,
}),
{
status: 429,
headers: {
'Content-Type': 'image/svg+xml; charset=utf-8',
'Content-Security-Policy': SVG_CSP_HEADER,
'Cache-Control': 'no-store',
...getRateLimitHeaders(rateLimitResult),
},
}
);
}

const { searchParams } = new URL(request.url);

// repo is required for spotlight
const repoName = searchParams.get('repo');
if (!repoName) {
return new NextResponse(buildInlineErrorSVG('Missing repo parameter'), {
status: 400,
headers: {
'Content-Type': 'image/svg+xml; charset=utf-8',
'Content-Security-Policy': SVG_CSP_HEADER,
},
});
return new NextResponse(
buildInlineErrorSVG('Missing repo parameter', {
bg: errTheme.bg,
accent: errTheme.accent,
text: errTheme.text,
radius: errTheme.radius,
width: 450,
height: 160,
}),
{
status: 400,
headers: {
'Content-Type': 'image/svg+xml; charset=utf-8',
'Content-Security-Policy': SVG_CSP_HEADER,
},
}
);
}

const parseResult = streakParamsSchema.safeParse(coerceQueryParams(searchParams));
if (!parseResult.success) {
return new NextResponse(buildInlineErrorSVG('Invalid parameters'), {
status: 400,
headers: {
'Content-Type': 'image/svg+xml; charset=utf-8',
'Content-Security-Policy': SVG_CSP_HEADER,
},
});
return new NextResponse(
buildInlineErrorSVG('Invalid parameters', {
bg: errTheme.bg,
accent: errTheme.accent,
text: errTheme.text,
radius: errTheme.radius,
width: 450,
height: 160,
}),
{
status: 400,
headers: {
'Content-Type': 'image/svg+xml; charset=utf-8',
'Content-Security-Policy': SVG_CSP_HEADER,
},
}
);
}

const { user, theme, bg, text, accent, format } = parseResult.data;
Expand Down Expand Up @@ -120,13 +136,23 @@
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return new NextResponse(buildInlineErrorSVG(message), {
status: message.includes('not found') ? 404 : 500,
headers: {
'Content-Type': 'image/svg+xml; charset=utf-8',
'Content-Security-Policy': SVG_CSP_HEADER,
'Cache-Control': 'no-store',
},
});
return new NextResponse(
buildInlineErrorSVG(message, {
bg: errTheme.bg,
accent: errTheme.accent,
text: errTheme.text,
radius: errTheme.radius,
width: 450,
height: 160,
}),
{
status: message.includes('not found') ? 404 : 500,
headers: {
'Content-Type': 'image/svg+xml; charset=utf-8',
'Content-Security-Policy': SVG_CSP_HEADER,
'Cache-Control': 'no-store',
},
}
);
}
}
78 changes: 46 additions & 32 deletions app/api/streak/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@
calculateStreak,
calculateMonthlyStats,
aggregateCalendars,
convertLocalToUtc,

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

View workflow job for this annotation

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

'convertLocalToUtc' is defined but never used
chunkDaysIntoWeeks,
normalizeCalendarToTimezone,
isLeapYear,

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

View workflow job for this annotation

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

'isLeapYear' is defined but never used
daysInYear,
} from '@/lib/calculate';
import {
Expand All @@ -30,6 +30,7 @@
generateSkylineSVG,
generateLanguagesSVG,
generateActivityGraphSVG,
buildInlineErrorSVG,
} from '@/lib/svg/generator';
import { generateConstellationSVG } from '@/lib/svg/constellation';
import { generateRadarSVG } from '@/lib/svg/radar';
Expand All @@ -46,7 +47,7 @@
ContributionCalendar,
StreakStats,
} from '@/types';
import { getNormalizedThemeKey, themes } from '@/lib/svg/themes';
import { getNormalizedThemeKey, themes, resolveErrorTheme } from '@/lib/svg/themes';
import { streakParamsSchema, coerceQueryParams } from '@/lib/validations';
import { sanitizeHexColor, sanitizeRadius, escapeXML } from '@/lib/svg/sanitizer';
import { getClientIp } from '@/utils/getClientIp';
Expand All @@ -62,26 +63,6 @@
const SVG_CSP_HEADER =
"default-src 'none'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; connect-src https://fonts.gstatic.com;";

function buildInlineErrorSVG(text: string): string {
const MAX_LINE = 48;
const chars = Array.from(text);
const truncated =
chars.length > MAX_LINE * 2 ? chars.slice(0, MAX_LINE * 2 - 1).join('') + '…' : text;
const truncatedChars = Array.from(truncated);
const line1 = escapeXML(truncatedChars.slice(0, MAX_LINE).join(''));
const line2 =
truncatedChars.length > MAX_LINE ? escapeXML(truncatedChars.slice(MAX_LINE).join('')) : 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
? `\n <text x="200" y="91" text-anchor="middle" dominant-baseline="central" fill="#ffcccc" font-family="sans-serif" font-size="13">${line2}</text>`
: ''
}
</svg>`;
}

function getMonthlyReferenceDate(year: string | undefined, timezone: string): Date | undefined {
if (!year) return undefined;

Expand Down Expand Up @@ -117,7 +98,13 @@
Object.values(fieldErrors.fieldErrors).flat()[0] ??
fieldErrors.formErrors[0] ??
'Invalid parameters';
const errorSvg = buildInlineErrorSVG(firstError);
const errTheme = resolveErrorTheme(searchParams);
const errorSvg = buildInlineErrorSVG(firstError, {
bg: errTheme.bg,
accent: errTheme.accent,
text: errTheme.text,
radius: errTheme.radius,
});
return new NextResponse(errorSvg, {
status: 400,
headers: {
Expand Down Expand Up @@ -775,7 +762,7 @@
},
});
} catch (error: unknown) {
return buildErrorResponse(error, parseResult, requestId);
return buildErrorResponse(error, parseResult, requestId, request);
}
}

Expand Down Expand Up @@ -813,7 +800,8 @@
function buildErrorResponse(
error: unknown,
parseResult: ParseResult,
requestId?: string
requestId?: string,
request?: Request
): NextResponse {
const rawMessage = error instanceof Error ? error.message : String(error);
const message = sanitizeErrorMessage(rawMessage);
Expand Down Expand Up @@ -869,17 +857,28 @@
rawMessage.toLowerCase().includes('validation') ||
rawMessage.toLowerCase().includes('strictly for organizations');

const errBg = `#${sanitizeHexColor(parseResult.success ? parseResult.data.bg : undefined, '0d1117')}`;
const searchParams = request ? new URL(request.url).searchParams : undefined;
const errTheme = resolveErrorTheme(searchParams);
const errBg =
parseResult.success && parseResult.data.bg
? `#${sanitizeHexColor(parseResult.data.bg, '0d1117')}`
: errTheme.bg;
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 errRadius = sanitizeRadius(parseResult.success ? parseResult.data.radius : undefined, 8);
const errSpeed = (parseResult.success && parseResult.data.speed) || '8s';
const errAccent = errAccentRaw ? `#${sanitizeHexColor(errAccentRaw, '58a6ff')}` : errTheme.accent;
const errText =
parseResult.success && parseResult.data.text
? `#${sanitizeHexColor(parseResult.data.text, 'c9d1d9')}`
: errTheme.text;
const errRadius =
parseResult.success && parseResult.data.radius !== undefined
? sanitizeRadius(parseResult.data.radius, 8)
: errTheme.radius;
const errSpeed = (parseResult.success && parseResult.data.speed) || errTheme.speed;

if (isRateLimit) {
const telemetry = getCircuitTelemetry();
Expand Down Expand Up @@ -931,7 +930,12 @@

// 3. Return a 400 Bad Request for Validation Errors
if (isValidationError) {
const validationSvg = buildInlineErrorSVG(message);
const validationSvg = buildInlineErrorSVG(message, {
bg: errBg,
accent: errAccent,
text: errText,
radius: errRadius,
});
const errorHeaders: Record<string, string> = {
'Content-Type': 'image/svg+xml; charset=utf-8',
'Cache-Control': 'no-store',
Expand All @@ -948,7 +952,12 @@

// 4. Return a 504 Gateway Timeout for aborted/timed out requests
if (isAbortError(error)) {
const timeoutSvg = buildInlineErrorSVG('Request timed out. Please try again later.');
const timeoutSvg = buildInlineErrorSVG('Request timed out. Please try again later.', {
bg: errBg,
accent: errAccent,
text: errText,
radius: errRadius,
});
const errorHeaders: Record<string, string> = {
'Content-Type': 'image/svg+xml; charset=utf-8',
'Cache-Control': 'no-store',
Expand All @@ -969,7 +978,12 @@
message,
});

const errorSvg = buildInlineErrorSVG('Something went wrong. Please try again later.');
const errorSvg = buildInlineErrorSVG('Something went wrong. Please try again later.', {
bg: errBg,
accent: errAccent,
text: errText,
radius: errRadius,
});
const errorHeaders: Record<string, string> = {
'Content-Type': 'image/svg+xml; charset=utf-8',
'Cache-Control': 'no-store',
Expand Down
13 changes: 9 additions & 4 deletions app/api/wakatime/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { NextResponse } from 'next/server';
import { getWakaTimeStats } from '@/services/wakatime/api';
import { generateWakaTimeSVG } from '@/lib/svg/wakatime';
import { buildInlineErrorSVG } from '@/lib/svg/generator';
import { resolveErrorTheme } from '@/lib/svg/themes';
import { wakatimeParamsSchema, coerceQueryParams } from '@/lib/validations';
import { optimizeSVG } from '@/lib/svg/optimizer';
import crypto from 'crypto';
Expand All @@ -20,10 +22,13 @@ export async function GET(request: Request) {
fieldErrors.formErrors[0] ??
'Invalid parameters';

const errorSvg = `<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="75" text-anchor="middle" dominant-baseline="central" fill="#ffcccc" font-family="sans-serif" font-size="13">${firstError}</text>
</svg>`;
const errTheme = resolveErrorTheme(searchParams);
const errorSvg = buildInlineErrorSVG(firstError, {
bg: errTheme.bg,
accent: errTheme.accent,
text: errTheme.text,
radius: errTheme.radius,
});

return new NextResponse(errorSvg, {
status: 400,
Expand Down
1 change: 1 addition & 0 deletions app/api/wrapped/route.empty-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ vi.mock('@/lib/svg/generator', () => ({
generateWrappedSVG: vi.fn(() => '<svg>mock-wrapped</svg>'),
generateNotFoundSVG: vi.fn(() => '<svg>mock-not-found</svg>'),
generateRateLimitSVG: vi.fn(() => '<svg>mock-rate-limit</svg>'),
buildInlineErrorSVG: vi.fn((text: string) => `<svg>mock-inline-error: ${text}</svg>`),
}));

vi.mock('@/utils/getClientIp', () => ({
Expand Down
4 changes: 4 additions & 0 deletions app/api/wrapped/route.error-resilience.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ vi.mock('@/lib/svg/generator', () => ({
generateWrappedSVG: vi.fn(),
generateNotFoundSVG: vi.fn().mockReturnValue('<svg>not found</svg>'),
generateRateLimitSVG: vi.fn().mockReturnValue('<svg>rate limit</svg>'),
buildInlineErrorSVG: vi.fn(
(text: string) =>
`<svg>mock-inline-error: ${text.replace(/[<>&'"]/g, (c: string) => (c === '<' ? '&lt;' : c === '>' ? '&gt;' : c === '&' ? '&amp;' : c === "'" ? '&apos;' : '&quot;'))}</svg>`
),
}));

// Import after mocks
Expand Down
Loading
Loading