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
21 changes: 0 additions & 21 deletions .claude/settings.local.json

This file was deleted.

3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,7 @@ Thumbs.db
.idea/
*.swp

# Claude Code local settings
.claude/

desktop.ini
51 changes: 28 additions & 23 deletions src/app/api/badge/commits/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { NextRequest, NextResponse } from "next/server";
import { generateBadgeSVG } from "../badge-utils";
import {
checkBadgeRateLimit,
getBadgeClientIp,
} from "@/lib/badge-rate-limit";

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 @@ -25,7 +30,7 @@ async function fetchCommitsThisMonth(
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 url = `${GITHUB_API}/search/commits?q=author:${username}+author-date:>=${sinceStr}&per_page=1`;
Expand All @@ -49,56 +54,56 @@ async function fetchCommitsThisMonth(
}

export async function GET(req: NextRequest) {
const ip = getBadgeClientIp(req);
const rateLimit = checkBadgeRateLimit(ip);

if (!rateLimit.allowed) {
return new NextResponse("Rate limit exceeded", {
status: 429,
headers: {
"Retry-After": String(
Math.max(rateLimit.reset - Math.floor(Date.now() / 1000), 1)
),
"X-RateLimit-Limit": "20",
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": String(rateLimit.reset),
},
});
}

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) {
if (!username || !GITHUB_USERNAME_RE.test(username)) {
return NextResponse.json(
{ error: "Invalid username" },
{ status: 400 }
);
}

console.log(`Fetching commits badge for user: ${username}`);

// 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)");
}

// 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",
});

return new NextResponse(svg, {
status: 200,
headers: {
"Content-Type": "image/svg+xml;charset=utf-8",
"Cache-Control": "max-age=3600, public",
"Cache-Control": "s-maxage=3600, stale-while-revalidate=86400",
"X-Content-Type-Options": "nosniff",
"X-RateLimit-Remaining": String(rateLimit.remaining),
"X-RateLimit-Reset": String(rateLimit.reset),
},
});
} catch (error) {
console.error("Error generating commits badge:", error);

// Return error badge
const svg = generateBadgeSVG({
label: "Commits",
value: "Error",
Expand Down
74 changes: 38 additions & 36 deletions src/app/api/badge/streak-shield/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { NextRequest, NextResponse } from "next/server";
import { generateBadgeSVG } from "../badge-utils";
import {
checkBadgeRateLimit,
getBadgeClientIp,
} from "@/lib/badge-rate-limit";

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 @@ -46,7 +51,7 @@ async function fetchStreak(
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 searchRes = await fetchGitHubWithToken(url, token);

if (!searchRes.ok) {
Expand All @@ -63,7 +68,6 @@ async function fetchStreak(
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 +78,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 @@ -101,7 +104,6 @@ async function fetchStreak(
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));
Expand All @@ -119,63 +121,63 @@ async function fetchStreak(
}

export async function GET(req: NextRequest) {
const ip = getBadgeClientIp(req);
const rateLimit = checkBadgeRateLimit(ip);

if (!rateLimit.allowed) {
return new NextResponse("Rate limit exceeded", {
status: 429,
headers: {
"Retry-After": String(
Math.max(rateLimit.reset - Math.floor(Date.now() / 1000), 1)
),
"X-RateLimit-Limit": "20",
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": String(rateLimit.reset),
},
});
}

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) {
if (!username || !GITHUB_USERNAME_RE.test(username)) {
return NextResponse.json(
{ error: "Invalid username" },
{ status: 400 }
);
}

console.log(`Fetching streak badge for user: ${username}`);

// 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)");
}

// 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=86400",
"X-Content-Type-Options": "nosniff",
"X-RateLimit-Remaining": String(rateLimit.remaining),
"X-RateLimit-Reset": String(rateLimit.reset),
},
});
} catch (error) {
console.error("Error generating streak badge:", error);

// Return error badge
const svg = generateBadgeSVG({
label: "DevTrack",
value: "Error",
color: "#ef4444",
labelColor: "#555",
});
label: "DevTrack",
value: "Error",
color: "#ef4444",
labelColor: "#555",
});

return new NextResponse(svg, {
status: 500,
headers: {
Expand Down
58 changes: 42 additions & 16 deletions src/app/api/goals/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ interface Goal {

type Recurrence = "none" | "weekly" | "monthly";

const VALID_RECURRENCES = ["none", "weekly", "monthly"] as const;
const MAX_TITLE_LEN = 100;
const MAX_UNIT_LEN = 30;
const MIN_TARGET = 1;
const MAX_TARGET = 10_000;

function getPeriodStart(recurrence: Recurrence): string {
const now = new Date();
if (recurrence === "weekly") {
Expand Down Expand Up @@ -106,21 +112,41 @@ export async function POST(req: Request) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const body = (await req.json()) as {
title?: string;
target?: number;
unit?: string;
recurrence?: Recurrence;
};
let body: unknown;
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}

if (!body.title || !body.target) {
return Response.json({ error: "title and target required" }, { status: 400 });
if (typeof body !== "object" || body === null) {
return Response.json({ error: "Invalid request body" }, { status: 400 });
}

const recurrence: Recurrence = body.recurrence ?? "none";
if (!["none", "weekly", "monthly"].includes(recurrence)) {
return Response.json({ error: "Invalid recurrence value" }, { status: 400 });
const { title, target, unit, recurrence } = body as Record<string, unknown>;

if (typeof title !== "string" || title.trim().length === 0) {
return Response.json({ error: "title must be a non-empty string" }, { status: 400 });
}
if (title.length > MAX_TITLE_LEN) {
return Response.json({ error: `title must be ${MAX_TITLE_LEN} characters or fewer` }, { status: 400 });
}
if (
typeof target !== "number" ||
!Number.isInteger(target) ||
target < MIN_TARGET ||
target > MAX_TARGET
) {
return Response.json(
{ error: `target must be an integer between ${MIN_TARGET} and ${MAX_TARGET}` },
{ status: 400 }
);
}

const safeUnit = typeof unit === "string" ? unit.slice(0, MAX_UNIT_LEN) : "commits";
const safeRecurrence: Recurrence = VALID_RECURRENCES.includes(recurrence as Recurrence)
? (recurrence as Recurrence)
: "none";

const user = await resolveAppUser(session.githubId, session.githubLogin);
if (!user) return Response.json({ error: "User not found" }, { status: 404 });
Expand All @@ -129,11 +155,11 @@ export async function POST(req: Request) {
.from("goals")
.insert({
user_id: user.id,
title: body.title,
target: body.target,
unit: body.unit ?? "commits",
recurrence,
period_start: getPeriodStart(recurrence),
title: title.trim(),
target,
unit: safeUnit,
recurrence: safeRecurrence,
period_start: getPeriodStart(safeRecurrence),
current: 0,
})
.select()
Expand Down
Loading
Loading