Skip to content

Commit b2b0357

Browse files
authored
fix(frontend): sanitize customize numeric url params (JhaSourav07#3133)
## Description Fixes JhaSourav07#3070 This fixes the Customize page hydration path for numeric URL params. The page was reading `radius`, `width`, `height`, and `grace` with `Number(...)`, so malformed values like `radius=abc` or `width=NaN` could become `NaN` in component state and then leak into the generated badge URL. I added a small numeric search-param reader that only accepts finite integers inside the expected UI ranges, then falls back to the same defaults the page already used. This keeps the customizer usable when someone lands on a malformed shared URL instead of hydrating broken control state. ## Pillar - [x] 🛠️ Other (Bug fix, refactoring, docs) ## What changed - Sanitized `radius`, `width`, `height`, and `grace` while reading Customize URL params. - Preserved valid values and existing fallback behavior. - Added a regression test for malformed numeric query params so `NaN` does not appear in the generated URL snippet. ## GSSoC 2026 This PR is raised as part of GSSoC 2026. Please add the relevant GSSoC label if it is not applied automatically. ## Visual Preview N/A — this is a URL state handling fix for the Customize controls. ## Validation ```bash npm run test -- app/customize/page.test.tsx npm run format npm run format:check npm run lint npm run typecheck npm run test -- services/github/refresh-rate-limiter.massive-scaling.test.ts ``` `npm run lint` passes with existing warnings outside this change. A full `npm run test` hit unrelated timing thresholds in `services/github/refresh-rate-limiter.massive-scaling.test.ts`; rerunning that file in isolation passed. ## Checklist before requesting a review: - [x] I have read the `CONTRIBUTING.md` file. - [x] I have tested these changes locally (`localhost:3000/api/streak?user=YOUR_USERNAME`). - [x] I have run `npm run format` and `npm run lint` locally and resolved all errors. - [x] My commits follow the Conventional Commits format. - [x] I have updated `README.md` if I added a new theme or URL parameter. - [x] I have starred the repo. - [x] I have made sure that I have only one commit to merge in this PR. - [x] The SVG output matches the CommitPulse quality standard.
2 parents 3bbb5cc + 669b2ca commit b2b0357

2 files changed

Lines changed: 80 additions & 19 deletions

File tree

app/customize/page.test.tsx

Lines changed: 55 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,22 @@ type MockContainerProps = HTMLAttributes<HTMLElement> & {
1414

1515
type MockControlsPanelProps = {
1616
username: string;
17-
timezone: string;
17+
radius: number;
1818
onUsernameChange: (value: string) => void;
19+
};
20+
21+
type MockAdvancedSettingsPanelProps = {
22+
timezone: string;
23+
badgeWidth: number | '';
24+
badgeHeight: number | '';
25+
grace: number;
1926
onTimezoneChange: (value: string) => void;
2027
};
2128

29+
const mockSearchParams = vi.hoisted(() => ({
30+
values: new Map<string, string>(),
31+
}));
32+
2233
vi.mock('next/link', () => ({
2334
default: ({ href, children, ...props }: MockLinkProps) => (
2435
<a href={href} {...props}>
@@ -39,7 +50,7 @@ vi.mock('next/navigation', () => ({
3950
replace: vi.fn(),
4051
}),
4152
useSearchParams: () => ({
42-
get: () => null,
53+
get: (key: string) => mockSearchParams.values.get(key) ?? null,
4354
}),
4455
}));
4556

@@ -48,33 +59,39 @@ vi.mock('@/components/InteractiveViewer', () => ({
4859
}));
4960

5061
vi.mock('./components/ControlsPanel', () => ({
51-
ControlsPanel: ({ username, onUsernameChange }: MockControlsPanelProps) => (
62+
ControlsPanel: ({ username, radius, onUsernameChange }: MockControlsPanelProps) => (
5263
<div>
5364
<input
5465
aria-label="Mock username"
5566
value={username}
5667
onChange={(event) => onUsernameChange(event.currentTarget.value)}
5768
/>
69+
<output aria-label="Mock radius">{String(radius)}</output>
5870
</div>
5971
),
6072
}));
6173

6274
vi.mock('./components/AdvancedSettingsPanel', () => ({
6375
AdvancedSettingsPanel: ({
6476
timezone,
77+
badgeWidth,
78+
badgeHeight,
79+
grace,
6580
onTimezoneChange,
66-
}: {
67-
timezone: string;
68-
onTimezoneChange: (value: string) => void;
69-
}) => (
70-
<select
71-
aria-label="Mock timezone"
72-
value={timezone}
73-
onChange={(event) => onTimezoneChange(event.currentTarget.value)}
74-
>
75-
<option value="UTC">UTC</option>
76-
<option value="Asia/Kolkata">Asia/Kolkata</option>
77-
</select>
81+
}: MockAdvancedSettingsPanelProps) => (
82+
<div>
83+
<select
84+
aria-label="Mock timezone"
85+
value={timezone}
86+
onChange={(event) => onTimezoneChange(event.currentTarget.value)}
87+
>
88+
<option value="UTC">UTC</option>
89+
<option value="Asia/Kolkata">Asia/Kolkata</option>
90+
</select>
91+
<output aria-label="Mock badge width">{String(badgeWidth)}</output>
92+
<output aria-label="Mock badge height">{String(badgeHeight)}</output>
93+
<output aria-label="Mock grace">{String(grace)}</output>
94+
</div>
7895
),
7996
}));
8097

@@ -86,6 +103,7 @@ vi.mock('./components/ExportPanel', () => ({
86103

87104
describe('CustomizePage timezone query params', () => {
88105
beforeEach(() => {
106+
mockSearchParams.values.clear();
89107
global.fetch = vi.fn().mockResolvedValue({
90108
ok: true,
91109
text: async () => '<svg></svg>',
@@ -126,4 +144,26 @@ describe('CustomizePage timezone query params', () => {
126144
expect(snippet).toContain('user=octocat');
127145
expect(snippet).toContain('tz=Asia%2FKolkata');
128146
});
147+
148+
it('falls back when numeric URL params are malformed', () => {
149+
mockSearchParams.values.set('user', 'octocat');
150+
mockSearchParams.values.set('radius', 'abc');
151+
mockSearchParams.values.set('width', 'NaN');
152+
mockSearchParams.values.set('height', 'nope');
153+
mockSearchParams.values.set('grace', 'bad');
154+
155+
render(<CustomizePage />);
156+
157+
expect(screen.getByLabelText('Mock radius').textContent).toBe('8');
158+
expect(screen.getByLabelText('Mock badge width').textContent).toBe('');
159+
expect(screen.getByLabelText('Mock badge height').textContent).toBe('');
160+
expect(screen.getByLabelText('Mock grace').textContent).toBe('1');
161+
162+
const snippet = screen.getByLabelText('Mock export snippet').textContent;
163+
expect(snippet).toContain('user=octocat');
164+
expect(snippet).not.toContain('radius=NaN');
165+
expect(snippet).not.toContain('width=NaN');
166+
expect(snippet).not.toContain('height=NaN');
167+
expect(snippet).not.toContain('grace=NaN');
168+
});
129169
});

app/customize/page.tsx

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,27 @@ import type {
2323
import { useDebounce } from '@/hooks/useDebounce';
2424
import { getExportSnippet, buildQueryParams } from './utils';
2525

26+
function readNumericSearchParam(
27+
searchParams: URLSearchParams,
28+
key: string,
29+
fallback: number | '',
30+
min?: number,
31+
max?: number
32+
): number | '' {
33+
const raw = searchParams.get(key);
34+
if (!raw) return fallback;
35+
36+
const parsed = Number(raw);
37+
if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
38+
return fallback;
39+
}
40+
41+
if (min !== undefined && parsed < min) return fallback;
42+
if (max !== undefined && parsed > max) return fallback;
43+
44+
return parsed;
45+
}
46+
2647
// ─── Main Page ────────────────────────────────────────────────────────────────
2748

2849
function CustomizePageInner(): ReactElement {
@@ -76,16 +97,16 @@ function CustomizePageInner(): ReactElement {
7697
setSpeed(searchParams.get('speed') ?? '8s');
7798
setFont((searchParams.get('font') as Font) ?? 'Inter');
7899
setYear(searchParams.get('year') ?? '');
79-
setRadius(Number(searchParams.get('radius') ?? 8));
100+
setRadius(readNumericSearchParam(searchParams, 'radius', 8, 0, 50) as number);
80101
setSize((searchParams.get('size') as BadgeSize) ?? 'medium');
81102
setHideTitle(searchParams.get('hide_title') === 'true');
82103
setHideBackground(searchParams.get('hide_background') === 'true');
83104
setHideStats(searchParams.get('hide_stats') === 'true');
84105
setViewMode((searchParams.get('view') as ViewMode) ?? 'default');
85106
setDeltaFormat((searchParams.get('delta_format') as DeltaFormat) ?? 'percent');
86-
setBadgeWidth(searchParams.get('width') ? Number(searchParams.get('width')) : '');
87-
setBadgeHeight(searchParams.get('height') ? Number(searchParams.get('height')) : '');
88-
setGrace(Number(searchParams.get('grace') ?? 1));
107+
setBadgeWidth(readNumericSearchParam(searchParams, 'width', '', 100, 1200));
108+
setBadgeHeight(readNumericSearchParam(searchParams, 'height', '', 80, 800));
109+
setGrace(readNumericSearchParam(searchParams, 'grace', 1, 0, 7) as number);
89110
setLanguage((searchParams.get('lang') as Language) ?? 'en');
90111
setTimezone((searchParams.get('tz') as Timezone) ?? 'UTC');
91112
// eslint-disable-next-line react-hooks/exhaustive-deps

0 commit comments

Comments
 (0)