Skip to content
Open
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
16 changes: 9 additions & 7 deletions src/app/api/badge/commits/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import {
checkBadgeRateLimit,
getBadgeClientIp,
} from "@/lib/badge-rate-limit";
import { normalizeGitHubUsername } from "@/lib/validate-github-username";

export const dynamic = "force-dynamic";

const GITHUB_API = "https://api.github.com";
const GITHUB_USERNAME_RE = /^[a-z\d](?:[a-z\d-]{0,37}[a-z\d])?$/i;

async function fetchGitHubWithToken(
url: string,
Expand All @@ -33,14 +33,16 @@ async function fetchCommitsThisMonth(
since.setDate(1);
const sinceStr = since.toISOString().slice(0, 10);

const url = `${GITHUB_API}/search/commits?q=author:${username}+author-date:>=${sinceStr}&per_page=1`;
const searchRes = await fetchGitHubWithToken(url, token);
const url = new URL(`${GITHUB_API}/search/commits`);
url.searchParams.set("q", `author:${username} author-date:>=${sinceStr}`);
url.searchParams.set("per_page", "1");
const searchRes = await fetchGitHubWithToken(url.toString(), token);

if (!searchRes.ok) {
const errorBody = await searchRes.text();
console.error(`GitHub API error fetching commits for ${username}:`, {
status: searchRes.status,
url,
url: url.toString(),
body: errorBody,
});
return 0;
Expand Down Expand Up @@ -72,11 +74,11 @@ export async function GET(req: NextRequest) {
}

try {
const username = req.nextUrl.searchParams.get("user");
const username = normalizeGitHubUsername(req.nextUrl.searchParams.get("user"));

if (!username || !GITHUB_USERNAME_RE.test(username)) {
if (!username) {
return NextResponse.json(
{ error: "Invalid username" },
{ error: "Invalid GitHub username" },
{ status: 400 }
);
}
Expand Down
18 changes: 11 additions & 7 deletions src/app/api/badge/streak-shield/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ import {
getBadgeClientIp,
} from "@/lib/badge-rate-limit";
import { dateDiffDays, toDateStr } from "@/lib/dateUtils";
import { normalizeGitHubUsername } from "@/lib/validate-github-username";

export const dynamic = "force-dynamic";

const GITHUB_API = "https://api.github.com";
const GITHUB_USERNAME_RE = /^[a-z\d](?:[a-z\d-]{0,37}[a-z\d])?$/i;

interface StreakData {
current: number;
Expand Down Expand Up @@ -42,16 +42,20 @@ async function fetchStreak(
since.setDate(since.getDate() - 90);
const sinceStr = since.toISOString().slice(0, 10);

const url = `${GITHUB_API}/search/commits?q=author:${username}+author-date:>=${sinceStr}&per_page=100&sort=author-date&order=desc`;
const url = new URL(`${GITHUB_API}/search/commits`);
url.searchParams.set("q", `author:${username} author-date:>=${sinceStr}`);
url.searchParams.set("per_page", "100");
url.searchParams.set("sort", "author-date");
url.searchParams.set("order", "desc");

const searchRes = await fetchGitHubWithToken(url, token);
const searchRes = await fetchGitHubWithToken(url.toString(), token);

if (!searchRes.ok) {
const errorBody = await searchRes.text();
const isRateLimited = searchRes.status === 403;
console.error(`GitHub API error fetching streak for ${username}:`, {
status: searchRes.status,
url,
url: url.toString(),
body: errorBody,
rateLimited: isRateLimited,
});
Expand Down Expand Up @@ -139,11 +143,11 @@ export async function GET(req: NextRequest) {
}

try {
const username = req.nextUrl.searchParams.get("user");
const username = normalizeGitHubUsername(req.nextUrl.searchParams.get("user"));

if (!username || !GITHUB_USERNAME_RE.test(username)) {
if (!username) {
return NextResponse.json(
{ error: "Invalid username" },
{ error: "Invalid GitHub username" },
{ status: 400 }
);
}
Expand Down
71 changes: 43 additions & 28 deletions src/app/api/metrics/compare/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { authOptions } from "@/lib/auth";
import { dateDiffDays, toDateStr } from "@/lib/dateUtils";
import { normalizeGitHubUsername } from "@/lib/validate-github-username";

export const dynamic = "force-dynamic";

Expand All @@ -13,28 +14,31 @@ export async function GET(req: NextRequest) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

let username = req.nextUrl.searchParams.get("username");
if (!username) {
const usernameParam = req.nextUrl.searchParams.get("username");
if (!usernameParam) {
return Response.json({ error: "Username required" }, { status: 400 });
}

username = username.trim();
let username = usernameParam.trim();
if (username.length === 0) {
return Response.json({ error: "Username required" }, { status: 400 });
}

if (username.length > 39 || !/^[a-zA-Z0-9-_]+$/.test(username)) {
return Response.json({ error: "Invalid username format" }, { status: 400 });
}

if (username === "me") {
username = session.githubLogin as string;
}

const normalizedUsername = normalizeGitHubUsername(username);
if (!normalizedUsername) {
return Response.json({ error: "Invalid GitHub username" }, { status: 400 });
}

const encodedUsername = encodeURIComponent(normalizedUsername);

// 1. Verify user exists
const userRes = await fetch(`${GITHUB_API}/users/${username}`, {
const userRes = await fetch(`${GITHUB_API}/users/${encodedUsername}`, {
headers: { Authorization: `Bearer ${session.accessToken}` },
next: { revalidate: 3600 },
cache: "no-store",
});

if (!userRes.ok) {
Expand All @@ -51,16 +55,22 @@ export async function GET(req: NextRequest) {
since30.setDate(since30.getDate() - 30);
const since30Str = since30.toISOString().slice(0, 10);

const commitsRes = await fetch(
`${GITHUB_API}/search/commits?q=author:${username}+author-date:>=${since90Str}&per_page=100&sort=author-date&order=desc`,
{
headers: {
Authorization: `Bearer ${session.accessToken}`,
Accept: "application/vnd.github+json",
},
next: { revalidate: 3600 },
}
const commitsUrl = new URL(`${GITHUB_API}/search/commits`);
commitsUrl.searchParams.set(
"q",
`author:${normalizedUsername} author-date:>=${since90Str}`
);
commitsUrl.searchParams.set("per_page", "100");
commitsUrl.searchParams.set("sort", "author-date");
commitsUrl.searchParams.set("order", "desc");

const commitsRes = await fetch(commitsUrl.toString(), {
headers: {
Authorization: `Bearer ${session.accessToken}`,
Accept: "application/vnd.github+json",
},
cache: "no-store",
});

let streak = 0;
let commits30d = 0;
Expand Down Expand Up @@ -103,9 +113,13 @@ export async function GET(req: NextRequest) {
}

// 3. Top Language from repos
const reposRes = await fetch(`${GITHUB_API}/users/${username}/repos?per_page=100&sort=pushed`, {
const reposUrl = new URL(`${GITHUB_API}/users/${encodedUsername}/repos`);
reposUrl.searchParams.set("per_page", "100");
reposUrl.searchParams.set("sort", "pushed");

const reposRes = await fetch(reposUrl.toString(), {
headers: { Authorization: `Bearer ${session.accessToken}` },
next: { revalidate: 3600 },
cache: "no-store",
});

if (reposRes.ok) {
Expand All @@ -121,21 +135,22 @@ export async function GET(req: NextRequest) {
}

// 4. PRs
const prsRes = await fetch(
`${GITHUB_API}/search/issues?q=type:pr+author:${username}&per_page=1`,
{
headers: { Authorization: `Bearer ${session.accessToken}` },
next: { revalidate: 3600 },
}
);
const prsUrl = new URL(`${GITHUB_API}/search/issues`);
prsUrl.searchParams.set("q", `type:pr author:${normalizedUsername}`);
prsUrl.searchParams.set("per_page", "1");

const prsRes = await fetch(prsUrl.toString(), {
headers: { Authorization: `Bearer ${session.accessToken}` },
cache: "no-store",
});
let prs = 0;
if (prsRes.ok) {
const prsData = await prsRes.json();
prs = prsData.total_count || 0;
}

return Response.json({
username,
username: normalizedUsername,
streak,
commits30d,
topLanguage,
Expand Down
20 changes: 18 additions & 2 deletions src/app/api/metrics/contributions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from "@/lib/metrics-cache";
import { supabaseAdmin } from "@/lib/supabase";
import { resolveAppUser } from "@/lib/resolve-user";
import { normalizeGitHubUsername } from "@/lib/validate-github-username";

export const dynamic = "force-dynamic";

Expand Down Expand Up @@ -94,8 +95,18 @@ async function fetchContributionsForAccount(
// Authenticated GitHub Search rate limits are low (~30 req/min). We handle 429/403
// responses gracefully by returning partial results rather than failing the endpoint.
while (page <= 10) {
const searchUrl = new URL(`${GITHUB_API}/search/commits`);
searchUrl.searchParams.set(
"q",
`author:${githubLogin} author-date:>=${sinceStr}`
);
searchUrl.searchParams.set("per_page", "100");
searchUrl.searchParams.set("page", String(page));
searchUrl.searchParams.set("sort", "author-date");
searchUrl.searchParams.set("order", "desc");

const searchRes = await fetch(
`${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${sinceStr}&per_page=100&page=${page}&sort=author-date&order=desc`,
searchUrl.toString(),
{
headers: {
Authorization: `Bearer ${token}`,
Expand Down Expand Up @@ -298,11 +309,16 @@ export async function GET(req: NextRequest) {
}

const accountId = req.nextUrl.searchParams.get("accountId");
const username = req.nextUrl.searchParams.get("username")?.trim();
const usernameParam = req.nextUrl.searchParams.get("username");
const username = usernameParam ? normalizeGitHubUsername(usernameParam) : null;
const bypass = isMetricsCacheBypassed(req);
const gitlabToken =
typeof session.gitlabToken === "string" ? session.gitlabToken : undefined;

if (usernameParam && !username) {
return Response.json({ error: "Invalid GitHub username" }, { status: 400 });
}

// Compare mode path: explicitly fetch contributions for a target username.
if (username) {
try {
Expand Down
20 changes: 20 additions & 0 deletions src/lib/validate-github-username.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
const GITHUB_USERNAME_RE = /^[a-z\d](?:[a-z\d-]{0,37}[a-z\d])?$/i;

export function isValidGitHubUsername(username: string): boolean {
return GITHUB_USERNAME_RE.test(username);
}

export function normalizeGitHubUsername(
value: string | null | undefined
): string | null {
if (typeof value !== "string") {
return null;
}

const trimmed = value.trim();
if (!trimmed) {
return null;
}

return isValidGitHubUsername(trimmed) ? trimmed : null;
}
58 changes: 58 additions & 0 deletions test/validate-github-username.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const ts = require("typescript");

function loadValidatorModule() {
const sourcePath = path.join(
__dirname,
"..",
"src",
"lib",
"validate-github-username.ts"
);
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "devtrack-github-user-"));
const outPath = path.join(outDir, "validate-github-username.cjs");
const source = fs.readFileSync(sourcePath, "utf8");
const output = ts.transpileModule(source, {
compilerOptions: {
esModuleInterop: true,
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
},
}).outputText;

fs.writeFileSync(outPath, output);
return require(outPath);
}

test("isValidGitHubUsername accepts valid GitHub usernames", () => {
const { isValidGitHubUsername } = loadValidatorModule();

assert.equal(isValidGitHubUsername("octocat"), true);
assert.equal(isValidGitHubUsername("dev-track"), true);
assert.equal(isValidGitHubUsername("A1b2C3"), true);
assert.equal(isValidGitHubUsername("a".repeat(39)), true);
});

test("isValidGitHubUsername rejects path and query injection attempts", () => {
const { isValidGitHubUsername } = loadValidatorModule();

assert.equal(isValidGitHubUsername("../search/repositories?q=test"), false);
assert.equal(isValidGitHubUsername("someuser+org:private-org"), false);
assert.equal(isValidGitHubUsername("-leadinghyphen"), false);
assert.equal(isValidGitHubUsername("trailinghyphen-"), false);
assert.equal(isValidGitHubUsername("a".repeat(40)), false);
});

test("normalizeGitHubUsername trims and validates input", () => {
const { normalizeGitHubUsername } = loadValidatorModule();

assert.equal(normalizeGitHubUsername(" octocat "), "octocat");
assert.equal(normalizeGitHubUsername(""), null);
assert.equal(normalizeGitHubUsername(" "), null);
assert.equal(normalizeGitHubUsername(null), null);
assert.equal(normalizeGitHubUsername("bad/user"), null);
});
Loading