Skip to content

Commit 7ad2df0

Browse files
committed
Resolves issue-#7796
1 parent ded12c8 commit 7ad2df0

10 files changed

Lines changed: 358 additions & 124 deletions

File tree

app/api/spotify/route.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import { NextResponse } from 'next/server';
44
import { getCurrentlyPlaying } from '@/services/spotify/api';
55
import { generateSpotifySVG } from '@/lib/svg/spotify';
6+
import { buildInlineErrorSVG } from '@/lib/svg/generator';
7+
import { resolveErrorTheme } from '@/lib/svg/themes';
68
import { spotifyParamsSchema, coerceQueryParams } from '@/lib/validations';
79
import { optimizeSVG } from '@/lib/svg/optimizer';
810
import crypto from 'crypto';
@@ -39,10 +41,13 @@ export async function GET(request: Request) {
3941
fieldErrors.formErrors[0] ??
4042
'Invalid parameters';
4143

42-
const errorSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="150" viewBox="0 0 400 150">
43-
<rect width="400" height="150" fill="#2d0000" rx="8"/>
44-
<text x="200" y="75" text-anchor="middle" dominant-baseline="central" fill="#ffcccc" font-family="sans-serif" font-size="13">${firstError}</text>
45-
</svg>`;
44+
const errTheme = resolveErrorTheme(searchParams);
45+
const errorSvg = buildInlineErrorSVG(firstError, {
46+
bg: errTheme.bg,
47+
accent: errTheme.accent,
48+
text: errTheme.text,
49+
radius: errTheme.radius,
50+
});
4651

4752
return new NextResponse(errorSvg, {
4853
status: 400,

app/api/spotlight/route.ts

Lines changed: 76 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import crypto from 'crypto';
22
import { NextResponse } from 'next/server';
33
import { fetchRepoDetails } from '@/lib/github';
44
import { generateRepoSpotlightSVG } from '@/lib/svg/repoSpotlight';
5-
import { getNormalizedThemeKey, themes } from '@/lib/svg/themes';
5+
import { getNormalizedThemeKey, themes, resolveErrorTheme } from '@/lib/svg/themes';
6+
import { buildInlineErrorSVG } from '@/lib/svg/generator';
67
import { streakParamsSchema, coerceQueryParams } from '@/lib/validations';
78
import { getClientIp } from '@/utils/getClientIp';
89
import { RateLimiter, getRateLimitHeaders } from '@/lib/rate-limit';
@@ -13,63 +14,78 @@ const spotlightLimiter = new RateLimiter(50, 60_000, 1);
1314
const SVG_CSP_HEADER =
1415
"default-src 'none'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; connect-src https://fonts.gstatic.com;";
1516

16-
function buildInlineErrorSVG(text: string): string {
17-
const MAX_LINE = 48;
18-
const truncated = text.length > MAX_LINE * 2 ? text.slice(0, MAX_LINE * 2 - 1) + '…' : text;
19-
const line1 = escapeXML(truncated.slice(0, MAX_LINE));
20-
const line2 = truncated.length > MAX_LINE ? escapeXML(truncated.slice(MAX_LINE)) : null;
21-
const textY = line2 ? '62' : '75';
22-
return `<svg xmlns="http://www.w3.org/2000/svg" width="450" height="160" viewBox="0 0 450 160">
23-
<rect width="450" height="160" fill="#2d0000" rx="8"/>
24-
<text x="225" y="${textY}" text-anchor="middle" dominant-baseline="central" fill="#ffcccc" font-family="sans-serif" font-size="13">${line1}</text>${
25-
line2
26-
? `\n <text x="225" y="91" text-anchor="middle" dominant-baseline="central" fill="#ffcccc" font-family="sans-serif" font-size="13">${line2}</text>`
27-
: ''
28-
}
29-
</svg>`;
30-
}
31-
3217
export async function GET(request: Request) {
18+
const { searchParams } = new URL(request.url);
19+
const errTheme = resolveErrorTheme(searchParams);
20+
3321
const ip = getClientIp(request);
3422
const rateLimitKey =
3523
ip && ip !== 'unknown' ? ip : `unknown:${request.headers.get('user-agent') ?? 'no-agent'}`;
3624

3725
const rateLimitResult = await spotlightLimiter.checkWithResult(rateLimitKey);
3826
if (!rateLimitResult.success) {
39-
return new NextResponse(buildInlineErrorSVG('Rate Limit Exceeded'), {
40-
status: 429,
41-
headers: {
42-
'Content-Type': 'image/svg+xml; charset=utf-8',
43-
'Content-Security-Policy': SVG_CSP_HEADER,
44-
'Cache-Control': 'no-store',
45-
...getRateLimitHeaders(rateLimitResult),
46-
},
47-
});
27+
return new NextResponse(
28+
buildInlineErrorSVG('Rate Limit Exceeded', {
29+
bg: errTheme.bg,
30+
accent: errTheme.accent,
31+
text: errTheme.text,
32+
radius: errTheme.radius,
33+
width: 450,
34+
height: 160,
35+
}),
36+
{
37+
status: 429,
38+
headers: {
39+
'Content-Type': 'image/svg+xml; charset=utf-8',
40+
'Content-Security-Policy': SVG_CSP_HEADER,
41+
'Cache-Control': 'no-store',
42+
...getRateLimitHeaders(rateLimitResult),
43+
},
44+
}
45+
);
4846
}
4947

50-
const { searchParams } = new URL(request.url);
51-
5248
// repo is required for spotlight
5349
const repoName = searchParams.get('repo');
5450
if (!repoName) {
55-
return new NextResponse(buildInlineErrorSVG('Missing repo parameter'), {
56-
status: 400,
57-
headers: {
58-
'Content-Type': 'image/svg+xml; charset=utf-8',
59-
'Content-Security-Policy': SVG_CSP_HEADER,
60-
},
61-
});
51+
return new NextResponse(
52+
buildInlineErrorSVG('Missing repo parameter', {
53+
bg: errTheme.bg,
54+
accent: errTheme.accent,
55+
text: errTheme.text,
56+
radius: errTheme.radius,
57+
width: 450,
58+
height: 160,
59+
}),
60+
{
61+
status: 400,
62+
headers: {
63+
'Content-Type': 'image/svg+xml; charset=utf-8',
64+
'Content-Security-Policy': SVG_CSP_HEADER,
65+
},
66+
}
67+
);
6268
}
6369

6470
const parseResult = streakParamsSchema.safeParse(coerceQueryParams(searchParams));
6571
if (!parseResult.success) {
66-
return new NextResponse(buildInlineErrorSVG('Invalid parameters'), {
67-
status: 400,
68-
headers: {
69-
'Content-Type': 'image/svg+xml; charset=utf-8',
70-
'Content-Security-Policy': SVG_CSP_HEADER,
71-
},
72-
});
72+
return new NextResponse(
73+
buildInlineErrorSVG('Invalid parameters', {
74+
bg: errTheme.bg,
75+
accent: errTheme.accent,
76+
text: errTheme.text,
77+
radius: errTheme.radius,
78+
width: 450,
79+
height: 160,
80+
}),
81+
{
82+
status: 400,
83+
headers: {
84+
'Content-Type': 'image/svg+xml; charset=utf-8',
85+
'Content-Security-Policy': SVG_CSP_HEADER,
86+
},
87+
}
88+
);
7389
}
7490

7591
const { user, theme, bg, text, accent, format } = parseResult.data;
@@ -120,13 +136,23 @@ export async function GET(request: Request) {
120136
});
121137
} catch (error: unknown) {
122138
const message = error instanceof Error ? error.message : String(error);
123-
return new NextResponse(buildInlineErrorSVG(message), {
124-
status: message.includes('not found') ? 404 : 500,
125-
headers: {
126-
'Content-Type': 'image/svg+xml; charset=utf-8',
127-
'Content-Security-Policy': SVG_CSP_HEADER,
128-
'Cache-Control': 'no-store',
129-
},
130-
});
139+
return new NextResponse(
140+
buildInlineErrorSVG(message, {
141+
bg: errTheme.bg,
142+
accent: errTheme.accent,
143+
text: errTheme.text,
144+
radius: errTheme.radius,
145+
width: 450,
146+
height: 160,
147+
}),
148+
{
149+
status: message.includes('not found') ? 404 : 500,
150+
headers: {
151+
'Content-Type': 'image/svg+xml; charset=utf-8',
152+
'Content-Security-Policy': SVG_CSP_HEADER,
153+
'Cache-Control': 'no-store',
154+
},
155+
}
156+
);
131157
}
132158
}

app/api/streak/route.ts

Lines changed: 46 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
generateSkylineSVG,
3131
generateLanguagesSVG,
3232
generateActivityGraphSVG,
33+
buildInlineErrorSVG,
3334
} from '@/lib/svg/generator';
3435
import { generateConstellationSVG } from '@/lib/svg/constellation';
3536
import { generateRadarSVG } from '@/lib/svg/radar';
@@ -46,7 +47,7 @@ import type {
4647
ContributionCalendar,
4748
StreakStats,
4849
} from '@/types';
49-
import { getNormalizedThemeKey, themes } from '@/lib/svg/themes';
50+
import { getNormalizedThemeKey, themes, resolveErrorTheme } from '@/lib/svg/themes';
5051
import { streakParamsSchema, coerceQueryParams } from '@/lib/validations';
5152
import { sanitizeHexColor, sanitizeRadius, escapeXML } from '@/lib/svg/sanitizer';
5253
import { getClientIp } from '@/utils/getClientIp';
@@ -62,26 +63,6 @@ const validationCache = _vc;
6263
const SVG_CSP_HEADER =
6364
"default-src 'none'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; connect-src https://fonts.gstatic.com;";
6465

65-
function buildInlineErrorSVG(text: string): string {
66-
const MAX_LINE = 48;
67-
const chars = Array.from(text);
68-
const truncated =
69-
chars.length > MAX_LINE * 2 ? chars.slice(0, MAX_LINE * 2 - 1).join('') + '…' : text;
70-
const truncatedChars = Array.from(truncated);
71-
const line1 = escapeXML(truncatedChars.slice(0, MAX_LINE).join(''));
72-
const line2 =
73-
truncatedChars.length > MAX_LINE ? escapeXML(truncatedChars.slice(MAX_LINE).join('')) : null;
74-
const textY = line2 ? '62' : '75';
75-
return `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="150" viewBox="0 0 400 150">
76-
<rect width="400" height="150" fill="#2d0000" rx="8"/>
77-
<text x="200" y="${textY}" text-anchor="middle" dominant-baseline="central" fill="#ffcccc" font-family="sans-serif" font-size="13">${line1}</text>${
78-
line2
79-
? `\n <text x="200" y="91" text-anchor="middle" dominant-baseline="central" fill="#ffcccc" font-family="sans-serif" font-size="13">${line2}</text>`
80-
: ''
81-
}
82-
</svg>`;
83-
}
84-
8566
function getMonthlyReferenceDate(year: string | undefined, timezone: string): Date | undefined {
8667
if (!year) return undefined;
8768

@@ -117,7 +98,13 @@ export async function GET(request: Request) {
11798
Object.values(fieldErrors.fieldErrors).flat()[0] ??
11899
fieldErrors.formErrors[0] ??
119100
'Invalid parameters';
120-
const errorSvg = buildInlineErrorSVG(firstError);
101+
const errTheme = resolveErrorTheme(searchParams);
102+
const errorSvg = buildInlineErrorSVG(firstError, {
103+
bg: errTheme.bg,
104+
accent: errTheme.accent,
105+
text: errTheme.text,
106+
radius: errTheme.radius,
107+
});
121108
return new NextResponse(errorSvg, {
122109
status: 400,
123110
headers: {
@@ -775,7 +762,7 @@ export async function GET(request: Request) {
775762
},
776763
});
777764
} catch (error: unknown) {
778-
return buildErrorResponse(error, parseResult, requestId);
765+
return buildErrorResponse(error, parseResult, requestId, request);
779766
}
780767
}
781768

@@ -813,7 +800,8 @@ function sanitizeErrorMessage(message: string): string {
813800
function buildErrorResponse(
814801
error: unknown,
815802
parseResult: ParseResult,
816-
requestId?: string
803+
requestId?: string,
804+
request?: Request
817805
): NextResponse {
818806
const rawMessage = error instanceof Error ? error.message : String(error);
819807
const message = sanitizeErrorMessage(rawMessage);
@@ -869,17 +857,28 @@ function buildErrorResponse(
869857
rawMessage.toLowerCase().includes('validation') ||
870858
rawMessage.toLowerCase().includes('strictly for organizations');
871859

872-
const errBg = `#${sanitizeHexColor(parseResult.success ? parseResult.data.bg : undefined, '0d1117')}`;
860+
const searchParams = request ? new URL(request.url).searchParams : undefined;
861+
const errTheme = resolveErrorTheme(searchParams);
862+
const errBg =
863+
parseResult.success && parseResult.data.bg
864+
? `#${sanitizeHexColor(parseResult.data.bg, '0d1117')}`
865+
: errTheme.bg;
873866
const errAccentRaw =
874867
(parseResult.success &&
875868
(Array.isArray(parseResult.data.accent)
876869
? parseResult.data.accent[parseResult.data.accent.length - 1]
877870
: parseResult.data.accent)) ||
878871
undefined;
879-
const errAccent = `#${sanitizeHexColor(errAccentRaw, '58a6ff')}`;
880-
const errText = `#${sanitizeHexColor(parseResult.success ? parseResult.data.text : undefined, 'c9d1d9')}`;
881-
const errRadius = sanitizeRadius(parseResult.success ? parseResult.data.radius : undefined, 8);
882-
const errSpeed = (parseResult.success && parseResult.data.speed) || '8s';
872+
const errAccent = errAccentRaw ? `#${sanitizeHexColor(errAccentRaw, '58a6ff')}` : errTheme.accent;
873+
const errText =
874+
parseResult.success && parseResult.data.text
875+
? `#${sanitizeHexColor(parseResult.data.text, 'c9d1d9')}`
876+
: errTheme.text;
877+
const errRadius =
878+
parseResult.success && parseResult.data.radius !== undefined
879+
? sanitizeRadius(parseResult.data.radius, 8)
880+
: errTheme.radius;
881+
const errSpeed = (parseResult.success && parseResult.data.speed) || errTheme.speed;
883882

884883
if (isRateLimit) {
885884
const telemetry = getCircuitTelemetry();
@@ -931,7 +930,12 @@ function buildErrorResponse(
931930

932931
// 3. Return a 400 Bad Request for Validation Errors
933932
if (isValidationError) {
934-
const validationSvg = buildInlineErrorSVG(message);
933+
const validationSvg = buildInlineErrorSVG(message, {
934+
bg: errBg,
935+
accent: errAccent,
936+
text: errText,
937+
radius: errRadius,
938+
});
935939
const errorHeaders: Record<string, string> = {
936940
'Content-Type': 'image/svg+xml; charset=utf-8',
937941
'Cache-Control': 'no-store',
@@ -948,7 +952,12 @@ function buildErrorResponse(
948952

949953
// 4. Return a 504 Gateway Timeout for aborted/timed out requests
950954
if (isAbortError(error)) {
951-
const timeoutSvg = buildInlineErrorSVG('Request timed out. Please try again later.');
955+
const timeoutSvg = buildInlineErrorSVG('Request timed out. Please try again later.', {
956+
bg: errBg,
957+
accent: errAccent,
958+
text: errText,
959+
radius: errRadius,
960+
});
952961
const errorHeaders: Record<string, string> = {
953962
'Content-Type': 'image/svg+xml; charset=utf-8',
954963
'Cache-Control': 'no-store',
@@ -969,7 +978,12 @@ function buildErrorResponse(
969978
message,
970979
});
971980

972-
const errorSvg = buildInlineErrorSVG('Something went wrong. Please try again later.');
981+
const errorSvg = buildInlineErrorSVG('Something went wrong. Please try again later.', {
982+
bg: errBg,
983+
accent: errAccent,
984+
text: errText,
985+
radius: errRadius,
986+
});
973987
const errorHeaders: Record<string, string> = {
974988
'Content-Type': 'image/svg+xml; charset=utf-8',
975989
'Cache-Control': 'no-store',

app/api/wakatime/route.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { NextResponse } from 'next/server';
22
import { getWakaTimeStats } from '@/services/wakatime/api';
33
import { generateWakaTimeSVG } from '@/lib/svg/wakatime';
4+
import { buildInlineErrorSVG } from '@/lib/svg/generator';
5+
import { resolveErrorTheme } from '@/lib/svg/themes';
46
import { wakatimeParamsSchema, coerceQueryParams } from '@/lib/validations';
57
import { optimizeSVG } from '@/lib/svg/optimizer';
68
import crypto from 'crypto';
@@ -20,10 +22,13 @@ export async function GET(request: Request) {
2022
fieldErrors.formErrors[0] ??
2123
'Invalid parameters';
2224

23-
const errorSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="150" viewBox="0 0 400 150">
24-
<rect width="400" height="150" fill="#2d0000" rx="8"/>
25-
<text x="200" y="75" text-anchor="middle" dominant-baseline="central" fill="#ffcccc" font-family="sans-serif" font-size="13">${firstError}</text>
26-
</svg>`;
25+
const errTheme = resolveErrorTheme(searchParams);
26+
const errorSvg = buildInlineErrorSVG(firstError, {
27+
bg: errTheme.bg,
28+
accent: errTheme.accent,
29+
text: errTheme.text,
30+
radius: errTheme.radius,
31+
});
2732

2833
return new NextResponse(errorSvg, {
2934
status: 400,

app/api/wrapped/route.empty-fallback.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ vi.mock('@/lib/svg/generator', () => ({
1414
generateWrappedSVG: vi.fn(() => '<svg>mock-wrapped</svg>'),
1515
generateNotFoundSVG: vi.fn(() => '<svg>mock-not-found</svg>'),
1616
generateRateLimitSVG: vi.fn(() => '<svg>mock-rate-limit</svg>'),
17+
buildInlineErrorSVG: vi.fn((text: string) => `<svg>mock-inline-error: ${text}</svg>`),
1718
}));
1819

1920
vi.mock('@/utils/getClientIp', () => ({

app/api/wrapped/route.error-resilience.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ vi.mock('@/lib/svg/generator', () => ({
2424
generateWrappedSVG: vi.fn(),
2525
generateNotFoundSVG: vi.fn().mockReturnValue('<svg>not found</svg>'),
2626
generateRateLimitSVG: vi.fn().mockReturnValue('<svg>rate limit</svg>'),
27+
buildInlineErrorSVG: vi.fn(
28+
(text: string) =>
29+
`<svg>mock-inline-error: ${text.replace(/[<>&'"]/g, (c: string) => (c === '<' ? '&lt;' : c === '>' ? '&gt;' : c === '&' ? '&amp;' : c === "'" ? '&apos;' : '&quot;'))}</svg>`
30+
),
2731
}));
2832

2933
// Import after mocks

0 commit comments

Comments
 (0)