diff --git a/src/app/api/badge/commits/route.ts b/src/app/api/badge/commits/route.ts index 7c9b3538..08450d5b 100644 --- a/src/app/api/badge/commits/route.ts +++ b/src/app/api/badge/commits/route.ts @@ -1,89 +1,85 @@ import { NextRequest, NextResponse } from "next/server"; import { generateBadgeSVG } from "../badge-utils"; +import { cacheGet, cacheSet } from "@/lib/metrics-cache"; export const dynamic = "force-dynamic"; const GITHUB_API = "https://api.github.com"; +const BADGE_CACHE_TTL = 3600; -async function fetchGitHubWithToken( - url: string, - token?: string -): Promise { - const headers: Record = { - Accept: "application/vnd.github+json", - }; - - if (token) { - headers.Authorization = `Bearer ${token}`; - } - - return fetch(url, { headers, cache: "no-store" }); -} +// GitHub username: alphanumeric and hyphens, no leading/trailing hyphens, +// no consecutive hyphens, 1–39 characters. +const GITHUB_USERNAME_RE = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/; async function fetchCommitsThisMonth( username: string, token?: string ): Promise { const since = new Date(); - since.setDate(1); // First day of current month + since.setDate(1); const sinceStr = since.toISOString().slice(0, 10); + const headers: Record = { + Accept: "application/vnd.github+json", + }; + if (token) headers.Authorization = `Bearer ${token}`; + const url = `${GITHUB_API}/search/commits?q=author:${username}+author-date:>=${sinceStr}&per_page=1`; - const searchRes = await fetchGitHubWithToken(url, token); + const res = await fetch(url, { headers, cache: "no-store" }); - if (!searchRes.ok) { - const errorBody = await searchRes.text(); - console.error(`GitHub API error fetching commits for ${username}:`, { - status: searchRes.status, - url, - body: errorBody, + if (!res.ok) { + console.error(`GitHub API error fetching commits for badge:`, { + status: res.status, + username, }); return 0; } - const data = (await searchRes.json()) as { - total_count: number; - }; - + const data = (await res.json()) as { total_count: number }; return data.total_count || 0; } +function errorBadge(): NextResponse { + const svg = generateBadgeSVG({ + label: "Commits", + value: "Error", + color: "#ef4444", + labelColor: "#333333", + }); + return new NextResponse(svg, { + status: 500, + headers: { + "Content-Type": "image/svg+xml;charset=utf-8", + "Cache-Control": "max-age=60, public", + "X-Content-Type-Options": "nosniff", + }, + }); +} + export async function GET(req: NextRequest) { try { const username = req.nextUrl.searchParams.get("user"); - if (!username) { - return NextResponse.json( - { error: "Missing 'user' query parameter" }, - { status: 400 } - ); - } - - // Validate username is a string and not too long - if (typeof username !== "string" || username.length > 50) { - return NextResponse.json( - { error: "Invalid username" }, - { status: 400 } - ); + if (!username || !GITHUB_USERNAME_RE.test(username)) { + return NextResponse.json({ error: "Invalid username" }, { status: 400 }); } - console.log(`Fetching commits badge for user: ${username}`); + const cacheKey = `badge:commits:${username}`; + const cached = await cacheGet(cacheKey); - // Use GITHUB_TOKEN env var if available for higher rate limits - const githubToken = process.env.GITHUB_TOKEN; - if (!githubToken) { - console.warn("⚠️ GITHUB_TOKEN not set - using unauthenticated API (60 req/hour limit)"); + let commits: number; + if (cached !== null) { + commits = cached; + } else { + const githubToken = process.env.GITHUB_TOKEN; + commits = await fetchCommitsThisMonth(username, githubToken); + await cacheSet(cacheKey, commits, BADGE_CACHE_TTL); } - // Fetch commits data - const commits = await fetchCommitsThisMonth(username, githubToken); - console.log(`Commits for ${username}: ${commits}`); - - // Generate SVG badge const svg = generateBadgeSVG({ label: "📦 Commits", value: `${commits} this month`, - color: "#6366f1", // DevTrack indigo + color: "#6366f1", labelColor: "#333333", }); @@ -97,21 +93,6 @@ export async function GET(req: NextRequest) { }); } catch (error) { console.error("Error generating commits badge:", error); - - // Return error badge - const svg = generateBadgeSVG({ - label: "Commits", - value: "Error", - color: "#ef4444", - labelColor: "#333333", - }); - - return new NextResponse(svg, { - status: 500, - headers: { - "Content-Type": "image/svg+xml;charset=utf-8", - "Cache-Control": "max-age=60, public", - }, - }); + return errorBadge(); } } diff --git a/src/app/api/badge/streak-shield/route.ts b/src/app/api/badge/streak-shield/route.ts index e89a07d7..e4a847ef 100644 --- a/src/app/api/badge/streak-shield/route.ts +++ b/src/app/api/badge/streak-shield/route.ts @@ -1,9 +1,15 @@ import { NextRequest, NextResponse } from "next/server"; import { generateBadgeSVG } from "../badge-utils"; +import { cacheGet, cacheSet } from "@/lib/metrics-cache"; export const dynamic = "force-dynamic"; const GITHUB_API = "https://api.github.com"; +const BADGE_CACHE_TTL = 3600; + +// GitHub username: alphanumeric and hyphens, no leading/trailing hyphens, +// no consecutive hyphens, 1–39 characters. +const GITHUB_USERNAME_RE = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/; interface StreakData { current: number; @@ -12,21 +18,6 @@ interface StreakData { totalActiveDays: number; } -async function fetchGitHubWithToken( - url: string, - token?: string -): Promise { - const headers: Record = { - Accept: "application/vnd.github+json", - }; - - if (token) { - headers.Authorization = `Bearer ${token}`; - } - - return fetch(url, { headers, cache: "no-store" }); -} - function dateDiffDays(a: string, b: string): number { return ( (new Date(b).getTime() - new Date(a).getTime()) / (1000 * 60 * 60 * 24) @@ -37,33 +28,31 @@ function toDateStr(d: Date): string { return d.toISOString().slice(0, 10); } -async function fetchStreak( - username: string, - token?: string -): Promise { +async function fetchStreak(username: string, token?: string): Promise { const since = new Date(); since.setDate(since.getDate() - 90); const sinceStr = since.toISOString().slice(0, 10); + const headers: Record = { + Accept: "application/vnd.github+json", + }; + if (token) headers.Authorization = `Bearer ${token}`; + const url = `${GITHUB_API}/search/commits?q=author:${username}+author-date:>=${sinceStr}&per_page=100&sort=author-date&order=desc`; - - const searchRes = await fetchGitHubWithToken(url, token); - - if (!searchRes.ok) { - const errorBody = await searchRes.text(); - console.error(`GitHub API error fetching streak for ${username}:`, { - status: searchRes.status, - url, - body: errorBody, + const res = await fetch(url, { headers, cache: "no-store" }); + + if (!res.ok) { + console.error(`GitHub API error fetching streak for badge:`, { + status: res.status, + username, }); return { current: 0, longest: 0, lastCommitDate: null, totalActiveDays: 0 }; } - const data = (await searchRes.json()) as { + const data = (await res.json()) as { items: Array<{ commit: { author: { date: string } } }>; }; - // Unique commit days const daySet: Record = {}; for (const item of data.items) { daySet[item.commit.author.date.slice(0, 10)] = true; @@ -74,7 +63,6 @@ async function fetchStreak( return { current: 0, longest: 0, lastCommitDate: null, totalActiveDays: 0 }; } - // Build streaks let longestStreak = 1; let currentRun = 1; const runs: { start: string; end: string; length: number }[] = []; @@ -86,26 +74,16 @@ async function fetchStreak( currentRun++; if (currentRun > longestStreak) longestStreak = currentRun; } else { - runs.push({ - start: runStart, - end: commitDays[i - 1], - length: currentRun, - }); + runs.push({ start: runStart, end: commitDays[i - 1], length: currentRun }); runStart = commitDays[i]; currentRun = 1; } } - runs.push({ - start: runStart, - end: commitDays[commitDays.length - 1], - length: currentRun, - }); + runs.push({ start: runStart, end: commitDays[commitDays.length - 1], length: currentRun }); - // Current streak: check if last commit day is today or yesterday const lastDay = commitDays[commitDays.length - 1]; const today = toDateStr(new Date()); const yesterday = toDateStr(new Date(Date.now() - 86400000)); - const lastRun = runs[runs.length - 1]; const currentStreak = lastRun.end === today || lastRun.end === yesterday ? lastRun.length : 0; @@ -118,70 +96,60 @@ async function fetchStreak( }; } +function errorBadge(): NextResponse { + const svg = generateBadgeSVG({ + label: "DevTrack", + value: "Error", + color: "#ef4444", + labelColor: "#555", + }); + return new NextResponse(svg, { + status: 500, + headers: { + "Content-Type": "image/svg+xml;charset=utf-8", + "Cache-Control": "max-age=60, public", + "X-Content-Type-Options": "nosniff", + }, + }); +} + export async function GET(req: NextRequest) { try { const username = req.nextUrl.searchParams.get("user"); - if (!username) { - return NextResponse.json( - { error: "Missing 'user' query parameter" }, - { status: 400 } - ); + if (!username || !GITHUB_USERNAME_RE.test(username)) { + return NextResponse.json({ error: "Invalid username" }, { status: 400 }); } - // Validate username is a string and not too long - if (typeof username !== "string" || username.length > 50) { - return NextResponse.json( - { error: "Invalid username" }, - { status: 400 } - ); - } - - console.log(`Fetching streak badge for user: ${username}`); + const cacheKey = `badge:streak:${username}`; + const cached = await cacheGet(cacheKey); - // Use GITHUB_TOKEN env var if available for higher rate limits - const githubToken = process.env.GITHUB_TOKEN; - if (!githubToken) { - console.warn("⚠️ GITHUB_TOKEN not set - using unauthenticated API (60 req/hour limit)"); + let streak: StreakData; + if (cached !== null) { + streak = cached; + } else { + const githubToken = process.env.GITHUB_TOKEN; + streak = await fetchStreak(username, githubToken); + await cacheSet(cacheKey, streak, BADGE_CACHE_TTL); } - // Fetch streak data - const streak = await fetchStreak(username, githubToken); - console.log(`Streak data for ${username}:`, streak); - - // Generate SVG badge const svg = generateBadgeSVG({ - label: "DevTrack", - value: `🔥 ${streak.current} day streak`, - color: streak.current > 0 ? "#4c1" : "#e05d44", - labelColor: "#555", -}); + label: "DevTrack", + value: `🔥 ${streak.current} day streak`, + color: streak.current > 0 ? "#4c1" : "#e05d44", + labelColor: "#555", + }); return new NextResponse(svg, { status: 200, headers: { "Content-Type": "image/svg+xml;charset=utf-8", - "Cache-Control": - "s-maxage=3600, stale-while-revalidate", + "Cache-Control": "s-maxage=3600, stale-while-revalidate", "X-Content-Type-Options": "nosniff", }, }); } catch (error) { console.error("Error generating streak badge:", error); - - // Return error badge - const svg = generateBadgeSVG({ - label: "DevTrack", - value: "Error", - color: "#ef4444", - labelColor: "#555", -}); - return new NextResponse(svg, { - status: 500, - headers: { - "Content-Type": "image/svg+xml;charset=utf-8", - "Cache-Control": "max-age=60, public", - }, - }); + return errorBadge(); } } diff --git a/src/middleware.ts b/src/middleware.ts index 615e699f..14a0408d 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -195,5 +195,5 @@ export async function middleware(req: NextRequest) { } export const config = { - matcher: "/api/metrics/:path*", + matcher: ["/api/metrics/:path*", "/api/badge/:path*"], };