Skip to content
Closed
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
113 changes: 47 additions & 66 deletions src/app/api/badge/commits/route.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
const headers: Record<string, string> = {
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<number> {
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<string, string> = {
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<number>(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",
});

Expand All @@ -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();
}
}
144 changes: 56 additions & 88 deletions src/app/api/badge/streak-shield/route.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -12,21 +18,6 @@ interface StreakData {
totalActiveDays: number;
}

async function fetchGitHubWithToken(
url: string,
token?: string
): Promise<Response> {
const headers: Record<string, string> = {
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)
Expand All @@ -37,33 +28,31 @@ function toDateStr(d: Date): string {
return d.toISOString().slice(0, 10);
}

async function fetchStreak(
username: string,
token?: string
): Promise<StreakData> {
async function fetchStreak(username: string, token?: string): Promise<StreakData> {
const since = new Date();
since.setDate(since.getDate() - 90);
const sinceStr = since.toISOString().slice(0, 10);

const headers: Record<string, string> = {
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<string, true> = {};
for (const item of data.items) {
daySet[item.commit.author.date.slice(0, 10)] = true;
Expand All @@ -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 }[] = [];
Expand All @@ -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;
Expand All @@ -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<StreakData>(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();
}
}
2 changes: 1 addition & 1 deletion src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,5 +195,5 @@ export async function middleware(req: NextRequest) {
}

export const config = {
matcher: "/api/metrics/:path*",
matcher: ["/api/metrics/:path*", "/api/badge/:path*"],
};
Loading