diff --git a/app/api/leetcode/route.test.ts b/app/api/leetcode/route.test.ts new file mode 100644 index 000000000..e71f98586 --- /dev/null +++ b/app/api/leetcode/route.test.ts @@ -0,0 +1,85 @@ +import { GET } from './route'; +import * as api from '@/services/leetcode/api'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('@/services/leetcode/api', () => ({ + getLeetCodeStats: vi.fn(), +})); + +describe('GET /api/leetcode', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('returns 400 for missing username parameter', async () => { + const req = new Request('http://localhost/api/leetcode'); + const res = await GET(req); + + expect(res.status).toBe(400); + expect(res.headers.get('Content-Type')).toContain('image/svg+xml'); + + const text = await res.text(); + expect(text).toContain('Username is required'); + }); + + it('returns 400 for invalid bg color parameter', async () => { + const req = new Request('http://localhost/api/leetcode?username=user&bg=invalid'); + const res = await GET(req); + + expect(res.status).toBe(400); + expect(res.headers.get('Content-Type')).toContain('image/svg+xml'); + + const text = await res.text(); + expect(text).toContain('bg must be a valid hex color'); + }); + + it('returns 200 SVG with valid user stats', async () => { + vi.mocked(api.getLeetCodeStats).mockResolvedValue({ + username: 'leetcode_dev', + totalSolved: 300, + easySolved: 100, + mediumSolved: 150, + hardSolved: 50, + ranking: 5000, + }); + + const req = new Request('http://localhost/api/leetcode?username=leetcode_dev&theme=dark'); + const res = await GET(req); + + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toBe('image/svg+xml; charset=utf-8'); + + const text = await res.text(); + expect(text).toContain("leetcode_dev's LeetCode Stats"); + expect(text).toContain('Rank #5,000'); + expect(text).toContain('300'); + }); + + it('returns 304 if ETag matches', async () => { + vi.mocked(api.getLeetCodeStats).mockResolvedValue({ + username: 'leetcode_dev', + totalSolved: 300, + easySolved: 100, + mediumSolved: 150, + hardSolved: 50, + ranking: 5000, + }); + + const req1 = new Request('http://localhost/api/leetcode?username=leetcode_dev'); + const res1 = await GET(req1); + const etag = res1.headers.get('ETag'); + expect(etag).toBeTruthy(); + + const req2 = new Request('http://localhost/api/leetcode?username=leetcode_dev', { + headers: { + 'If-None-Match': etag as string, + }, + }); + const res2 = await GET(req2); + expect(res2.status).toBe(304); + }); +}); diff --git a/app/api/leetcode/route.ts b/app/api/leetcode/route.ts new file mode 100644 index 000000000..e5a596777 --- /dev/null +++ b/app/api/leetcode/route.ts @@ -0,0 +1,83 @@ +import { NextResponse } from 'next/server'; +import { getLeetCodeStats } from '@/services/leetcode/api'; +import { generateLeetCodeSVG } from '@/lib/svg/leetcode'; +import { buildInlineErrorSVG } from '@/lib/svg/generator'; +import { resolveErrorTheme } from '@/lib/svg/themes'; +import { leetcodeParamsSchema, coerceQueryParams } from '@/lib/validations'; +import { optimizeSVG } from '@/lib/svg/optimizer'; +import crypto from 'crypto'; + +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; img-src data:;"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + + const parseResult = leetcodeParamsSchema.safeParse(coerceQueryParams(searchParams)); + + if (!parseResult.success) { + const fieldErrors = parseResult.error.flatten(); + const firstError = + Object.values(fieldErrors.fieldErrors).flat()[0] ?? + fieldErrors.formErrors[0] ?? + 'Invalid parameters'; + + 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: { + 'Content-Type': 'image/svg+xml', + 'Cache-Control': 'no-store', + 'Content-Security-Policy': SVG_CSP_HEADER, + }, + }); + } + + const params = parseResult.data; + + const stats = await getLeetCodeStats(params.username); + + let svg = generateLeetCodeSVG(stats, params); + + if (params.minify) { + svg = optimizeSVG(svg); + } + + const isRefreshRequested = params.refresh || params.bypassCache; + const cacheControl = isRefreshRequested + ? 'no-cache, no-store, must-revalidate' + : 'public, max-age=30, s-maxage=30, stale-while-revalidate=30'; + + const etag = crypto.createHash('sha256').update(svg).digest('hex'); + const weakEtag = `W/"${etag}"`; + const ifNoneMatch = request.headers.get('if-none-match'); + + if (ifNoneMatch) { + const etags = ifNoneMatch.split(',').map((e) => e.trim()); + if (etags.includes(weakEtag) || etags.includes(`"${etag}"`)) { + return new NextResponse(null, { + status: 304, + headers: { + 'Cache-Control': cacheControl, + ETag: weakEtag, + }, + }); + } + } + + return new NextResponse(svg, { + headers: { + 'Content-Type': 'image/svg+xml; charset=utf-8', + 'Cache-Control': cacheControl, + 'Content-Security-Policy': SVG_CSP_HEADER, + ETag: weakEtag, + }, + }); +} diff --git a/app/contact/page.test.tsx b/app/contact/page.test.tsx index dade0894e..e1d001afd 100644 --- a/app/contact/page.test.tsx +++ b/app/contact/page.test.tsx @@ -10,7 +10,6 @@ import ContactPage from './page'; vi.mock('framer-motion', () => ({ AnimatePresence: ({ children }: { children: ReactNode }) => <>{children}, motion: { - // eslint-disable-next-line @typescript-eslint/no-unused-vars div: ({ children, initial, @@ -24,7 +23,7 @@ vi.mock('framer-motion', () => ({ }: React.HTMLAttributes & Record) => (
{children}
), - // eslint-disable-next-line @typescript-eslint/no-unused-vars + form: ({ children, onSubmit, @@ -38,7 +37,7 @@ vi.mock('framer-motion', () => ({ {children} ), - // eslint-disable-next-line @typescript-eslint/no-unused-vars + p: ({ children, initial, @@ -49,7 +48,7 @@ vi.mock('framer-motion', () => ({ }: React.HTMLAttributes & Record) => (

{children}

), - // eslint-disable-next-line @typescript-eslint/no-unused-vars + button: ({ children, whileTap, diff --git a/lib/resume-parser.ts b/lib/resume-parser.ts index 202e498eb..e61e268ce 100644 --- a/lib/resume-parser.ts +++ b/lib/resume-parser.ts @@ -136,8 +136,9 @@ async function extractTextFromBuffer(buffer: Buffer, mimeType: string): Promise< let rawText = ''; if (mimeType === 'application/pdf') { - try { - if (buffer.toString('utf-8', 0, 4) === '%PDF') { + const header = buffer.toString('utf-8', 0, 4); + if (header === '%PDF') { + try { const { PDFParse } = await import('pdf-parse'); const parser = new PDFParse({ data: buffer }); @@ -145,36 +146,48 @@ async function extractTextFromBuffer(buffer: Buffer, mimeType: string): Promise< await parser.destroy(); rawText = result.text; - } else { - rawText = buffer.toString('utf-8'); + } catch (error) { + console.warn('Failed to parse PDF using pdf-parse:', error); + rawText = ''; } - } catch (error) { - console.warn('Failed to parse PDF using pdf-parse, falling back to UTF-8 decoding:', error); + } else { rawText = buffer.toString('utf-8'); } } else if ( mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ) { - try { - if (buffer.toString('utf-8', 0, 2) === 'PK') { + const header = buffer.toString('utf-8', 0, 2); + if (header === 'PK') { + try { const mammothModule = await import('mammoth'); const mammothParser = ((mammothModule as unknown as { default?: unknown }).default || mammothModule) as typeof mammothModule; const result = await mammothParser.extractRawText({ buffer }); rawText = result.value; - } else { - rawText = buffer.toString('utf-8'); + } catch (error) { + console.warn('Failed to parse DOCX using mammoth:', error); + rawText = ''; } - } catch (error) { - console.warn('Failed to parse DOCX using mammoth, falling back to UTF-8 decoding:', error); + } else { rawText = buffer.toString('utf-8'); } } else { rawText = buffer.toString('utf-8'); } + try { + if (rawText.includes('Ã')) { + const fixedText = Buffer.from(rawText, 'latin1').toString('utf-8'); + if (!fixedText.includes('\uFFFD')) { + rawText = fixedText; + } + } + } catch (_e) { + // Ignore encoding fix errors + } + const printable = rawText - .replace(/[^\x20-\x7E\n\r]/g, ' ') + .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F\uFFFD]/g, '') .replace(/[ \t]+/g, ' ') .replace(/\r/g, '') .trim(); diff --git a/lib/svg/leetcode.test.ts b/lib/svg/leetcode.test.ts new file mode 100644 index 000000000..449a84f6b --- /dev/null +++ b/lib/svg/leetcode.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { generateLeetCodeSVG, getLeetCodeTheme } from './leetcode'; +import type { LeetCodeStatData } from '../../services/leetcode/api'; +import { leetcodeParamsSchema } from '../validations'; + +describe('LeetCode SVG Generator', () => { + const defaultParams = leetcodeParamsSchema.parse({ username: 'testuser' }); + + it('renders error state correctly', () => { + const stats: LeetCodeStatData = { + username: 'testuser', + totalSolved: 0, + easySolved: 0, + mediumSolved: 0, + hardSolved: 0, + ranking: 0, + error: 'User not found', + }; + const svg = generateLeetCodeSVG(stats, defaultParams); + expect(svg).toContain('LeetCode Stats'); + expect(svg).toContain('User not found'); + }); + + it('renders full stats correctly', () => { + const stats: LeetCodeStatData = { + username: 'john_doe', + totalSolved: 500, + easySolved: 200, + mediumSolved: 250, + hardSolved: 50, + ranking: 15420, + }; + + const svg = generateLeetCodeSVG(stats, defaultParams); + + expect(svg).toContain("john_doe's LeetCode Stats"); + expect(svg).toContain('Rank #15,420'); + expect(svg).toContain('Total Solved'); + expect(svg).toContain('500'); + expect(svg).toContain('Easy'); + expect(svg).toContain('200'); + expect(svg).toContain('Medium'); + expect(svg).toContain('250'); + expect(svg).toContain('Hard'); + expect(svg).toContain('50'); + }); + + it('supports theme customization', () => { + const customParams = leetcodeParamsSchema.parse({ + username: 'testuser', + theme: 'dracula', + bg: '1e1e2e', + text: 'f5e0dc', + accent: 'cba6f7', + }); + + const theme = getLeetCodeTheme(customParams); + expect(theme.bg).toBe('1e1e2e'); + expect(theme.text).toBe('f5e0dc'); + expect(theme.accent).toBe('cba6f7'); + + const stats: LeetCodeStatData = { + username: 'testuser', + totalSolved: 10, + easySolved: 5, + mediumSolved: 3, + hardSolved: 2, + ranking: 100, + }; + + const svg = generateLeetCodeSVG(stats, customParams); + expect(svg).toContain('fill="#1e1e2e"'); + }); +}); diff --git a/lib/svg/leetcode.ts b/lib/svg/leetcode.ts new file mode 100644 index 000000000..77a375127 --- /dev/null +++ b/lib/svg/leetcode.ts @@ -0,0 +1,101 @@ +import type { LeetCodeStatData } from '../../services/leetcode/api'; +import type { LeetCodeParams } from '../validations'; +import { getNormalizedThemeKey, themes } from './themes'; +import { escapeXML } from './sanitizer'; +import { DEFAULT_FONTS_BASE64 } from './fonts'; + +export function getLeetCodeTheme(params: LeetCodeParams) { + const themeKey = getNormalizedThemeKey(params.theme); + const selectedTheme = themes[themeKey] || themes.dark; + + return { + bg: params.bg || selectedTheme.bg, + text: params.text || selectedTheme.text, + accent: params.accent || selectedTheme.accent, + }; +} + +export function generateLeetCodeSVG(stats: LeetCodeStatData, params: LeetCodeParams): string { + const theme = getLeetCodeTheme(params); + + const bg = theme.bg.startsWith('#') ? theme.bg : `#${theme.bg}`; + const text = theme.text.startsWith('#') ? theme.text : `#${theme.text}`; + + const width = params.width || 400; + const height = params.height || 150; + const radius = params.radius !== undefined ? params.radius : 8; + + let content = ''; + + if (stats.error) { + content = ` + ${escapeXML(stats.error)} + `; + } else { + const title = escapeXML(`${stats.username}'s LeetCode Stats`); + + // Calculate percentages + const easyPercent = stats.totalSolved > 0 ? (stats.easySolved / stats.totalSolved) * 100 : 0; + const mediumPercent = + stats.totalSolved > 0 ? (stats.mediumSolved / stats.totalSolved) * 100 : 0; + const hardPercent = stats.totalSolved > 0 ? (stats.hardSolved / stats.totalSolved) * 100 : 0; + + // Colors for difficulty + const easyColor = '#00b8a3'; + const mediumColor = '#ffc01e'; + const hardColor = '#ff375f'; + + let bars = ''; + if (stats.totalSolved > 0) { + const totalBarWidth = width - 60; + const easyWidth = (easyPercent / 100) * totalBarWidth; + const mediumWidth = (mediumPercent / 100) * totalBarWidth; + const hardWidth = (hardPercent / 100) * totalBarWidth; + + bars = ` + + + + `; + } + + content = ` + ${title} + Rank: ${stats.ranking.toLocaleString()} + + + Total Solved: ${stats.totalSolved} + + + Easy: ${stats.easySolved} + Medium: ${stats.mediumSolved} + Hard: ${stats.hardSolved} + + + + ${bars} + `; + } + + return ` + + + + ${content} + + `.trim(); +} diff --git a/lib/validations.ts b/lib/validations.ts index a30dbea37..5f42ea7fa 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -1005,6 +1005,45 @@ export const wakatimeParamsSchema = z.object({ bypassCache: z.string().optional().transform(toRefreshFlag), }); +export const leetcodeParamsSchema = z.object({ + username: z + .string({ error: 'Username is required' }) + .trim() + .min(1, { message: 'Username is required' }), + theme: z.string().optional().transform(toValidTheme).default('dark'), + bg: z + .string() + .optional() + .refine((val) => !val || /^[0-9a-fA-F]{3,4}$|^[0-9a-fA-F]{6,8}$/.test(val.replace('#', '')), { + message: 'bg must be a valid hex color (with or without #)', + }) + .transform((val) => (val ? sanitizeHexColor(val, '0d1117') : undefined)), + text: z + .string() + .optional() + .refine((val) => !val || /^[0-9a-fA-F]{3,4}$|^[0-9a-fA-F]{6,8}$/.test(val.replace('#', '')), { + message: 'text must be a valid hex color (with or without #)', + }) + .transform((val) => (val ? sanitizeHexColor(val, 'ffffff') : undefined)), + accent: z + .string() + .optional() + .refine((val) => !val || /^[0-9a-fA-F]{3,4}$|^[0-9a-fA-F]{6,8}$/.test(val.replace('#', '')), { + message: 'accent must be a valid hex color', + }) + .transform((val) => (val ? sanitizeHexColor(val, '00ffaa') : undefined)), + width: dimensionParam('width', 100, 1200).default(450), + height: dimensionParam('height', 80, 800).default(200), + radius: z + .string() + .transform((val) => sanitizeRadius(val, 8)) + .default(8), + glow: z.string().optional().transform(toGlowFlag).default(true), + minify: z.string().optional().transform(toMinifyFlag).default(true), + refresh: z.string().optional().transform(toRefreshFlag), + bypassCache: z.string().optional().transform(toRefreshFlag), +}); + export const notifyPostSchema = z.object({ username: z .string({ error: 'Username is required.' }) @@ -1219,4 +1258,5 @@ export type NotifyGetParams = z.infer; export type ResumeConfirmData = z.infer; export type SpotifyParams = z.infer; export type WakatimeParams = z.infer; +export type LeetCodeParams = z.infer; export type LanguagesParams = z.infer; diff --git a/services/github/ci-analytics.ts b/services/github/ci-analytics.ts index fdcdd5b8d..6c67ebe79 100644 --- a/services/github/ci-analytics.ts +++ b/services/github/ci-analytics.ts @@ -14,7 +14,7 @@ import type { const GITHUB_REST_URL = 'https://api.github.com'; const MAX_REPO_PAGES = 2; -const MAX_ACTION_PAGES = 1; +const MAX_ACTION_PAGES = 2; const MAX_FETCH_TARGETS = 5; const cache = new DistributedCache(500); diff --git a/services/leetcode/api.test.ts b/services/leetcode/api.test.ts new file mode 100644 index 000000000..bbafcb102 --- /dev/null +++ b/services/leetcode/api.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getLeetCodeStats } from './api'; + +describe('getLeetCodeStats', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('returns error when username is empty', async () => { + const res = await getLeetCodeStats(''); + expect(res.error).toBe('Username is required'); + expect(res.totalSolved).toBe(0); + }); + + it('fetches and parses LeetCode stats successfully', async () => { + const mockData = { + data: { + matchedUser: { + username: 'testuser', + submitStats: { + acSubmissionNum: [ + { difficulty: 'All', count: 350 }, + { difficulty: 'Easy', count: 150 }, + { difficulty: 'Medium', count: 150 }, + { difficulty: 'Hard', count: 50 }, + ], + }, + profile: { + ranking: 12345, + }, + }, + }, + }; + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockData, + }) + ); + + const res = await getLeetCodeStats('testuser'); + expect(res.username).toBe('testuser'); + expect(res.totalSolved).toBe(350); + expect(res.easySolved).toBe(150); + expect(res.mediumSolved).toBe(150); + expect(res.hardSolved).toBe(50); + expect(res.ranking).toBe(12345); + expect(res.error).toBeUndefined(); + }); + + it('handles user not found error response', async () => { + const mockData = { + data: { + matchedUser: null, + }, + }; + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockData, + }) + ); + + const res = await getLeetCodeStats('nonexistentuser'); + expect(res.error).toBe('User not found'); + expect(res.totalSolved).toBe(0); + }); + + it('handles non-200 HTTP response status', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 500, + }) + ); + + const res = await getLeetCodeStats('someuser'); + expect(res.error).toBe('LeetCode API error (500)'); + }); + + it('handles network failure gracefully', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network failure'))); + + const res = await getLeetCodeStats('someuser'); + expect(res.error).toBe('Failed to fetch LeetCode data'); + }); +}); diff --git a/services/leetcode/api.ts b/services/leetcode/api.ts new file mode 100644 index 000000000..1ca639232 --- /dev/null +++ b/services/leetcode/api.ts @@ -0,0 +1,140 @@ +// services/leetcode/api.ts + +export interface LeetCodeDifficultyStats { + difficulty: string; + count: number; + submissions?: number; +} + +export interface LeetCodeStatData { + username: string; + totalSolved: number; + easySolved: number; + mediumSolved: number; + hardSolved: number; + ranking: number; + acceptanceRate?: number; + error?: string; +} + +const LEETCODE_GRAPHQL_ENDPOINT = 'https://leetcode.com/graphql'; + +const USER_PROFILE_QUERY = ` + query getUserProfile($username: String!) { + matchedUser(username: $username) { + username + submitStats: submitStatsGlobal { + acSubmissionNum { + difficulty + count + submissions + } + } + profile { + ranking + reputation + } + } + } +`; + +export async function getLeetCodeStats(username: string): Promise { + const trimmedUser = username ? username.trim() : ''; + + if (!trimmedUser) { + return { + username: '', + totalSolved: 0, + easySolved: 0, + mediumSolved: 0, + hardSolved: 0, + ranking: 0, + error: 'Username is required', + }; + } + + try { + const response = await fetch(LEETCODE_GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'CommitPulse-LeetCodeCard/1.0', + }, + body: JSON.stringify({ + query: USER_PROFILE_QUERY, + variables: { username: trimmedUser }, + }), + cache: 'no-store', + }); + + if (!response.ok) { + console.warn(`LeetCode API request failed with status: ${response.status}`); + return { + username: trimmedUser, + totalSolved: 0, + easySolved: 0, + mediumSolved: 0, + hardSolved: 0, + ranking: 0, + error: `LeetCode API error (${response.status})`, + }; + } + + const json = await response.json(); + + if (json.errors || !json.data?.matchedUser) { + return { + username: trimmedUser, + totalSolved: 0, + easySolved: 0, + mediumSolved: 0, + hardSolved: 0, + ranking: 0, + error: 'User not found', + }; + } + + const matchedUser = json.data.matchedUser; + const submissionStats: LeetCodeDifficultyStats[] = + matchedUser.submitStats?.acSubmissionNum || []; + const profile = matchedUser.profile || {}; + + let totalSolved = 0; + let easySolved = 0; + let mediumSolved = 0; + let hardSolved = 0; + + for (const stat of submissionStats) { + const diff = stat.difficulty ? stat.difficulty.toLowerCase() : ''; + if (diff === 'all') { + totalSolved = stat.count; + } else if (diff === 'easy') { + easySolved = stat.count; + } else if (diff === 'medium') { + mediumSolved = stat.count; + } else if (diff === 'hard') { + hardSolved = stat.count; + } + } + + return { + username: matchedUser.username || trimmedUser, + totalSolved, + easySolved, + mediumSolved, + hardSolved, + ranking: profile.ranking || 0, + }; + } catch (error) { + console.warn('Error fetching LeetCode stats:', error); + return { + username: trimmedUser, + totalSolved: 0, + easySolved: 0, + mediumSolved: 0, + hardSolved: 0, + ranking: 0, + error: 'Failed to fetch LeetCode data', + }; + } +}