diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index bf296420..00000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(git add:*)", - "Bash(git commit:*)", - "Bash(npm --version)", - "Bash(psql --version)", - "Bash(gh pr:*)", - "Bash(gh issue:*)", - "Bash(/api/auth/callback/github)", - "Bash(gh label:*)", - "Bash(gh repo:*)", - "Bash(metricsController.ts)", - "Bash(git pull:*)", - "Bash(git stash:*)", - "Bash(git push:*)", - "Bash(gh release:*)", - "Bash(git rm:*)" - ] - } -} diff --git a/.gitignore b/.gitignore index 236cc8a3..d7a7de4b 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,7 @@ Thumbs.db .idea/ *.swp +# Claude Code local settings +.claude/ + desktop.ini diff --git a/src/app/api/badge/commits/route.ts b/src/app/api/badge/commits/route.ts index 7c9b3538..61feda02 100644 --- a/src/app/api/badge/commits/route.ts +++ b/src/app/api/badge/commits/route.ts @@ -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, @@ -25,7 +30,7 @@ async function fetchCommitsThisMonth( 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 url = `${GITHUB_API}/search/commits?q=author:${username}+author-date:>=${sinceStr}&per_page=1`; @@ -49,41 +54,40 @@ 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", }); @@ -91,14 +95,15 @@ export async function GET(req: NextRequest) { 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", diff --git a/src/app/api/badge/streak-shield/route.ts b/src/app/api/badge/streak-shield/route.ts index e89a07d7..d1402949 100644 --- a/src/app/api/badge/streak-shield/route.ts +++ b/src/app/api/badge/streak-shield/route.ts @@ -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; @@ -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) { @@ -63,7 +68,6 @@ async function fetchStreak( 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 +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 }[] = []; @@ -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)); @@ -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: { diff --git a/src/app/api/goals/route.ts b/src/app/api/goals/route.ts index 9f013923..aa198624 100644 --- a/src/app/api/goals/route.ts +++ b/src/app/api/goals/route.ts @@ -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") { @@ -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 || typeof body.target !== "number" || body.target <= 0) { - return Response.json({ error: "title and positive 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; + + 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 }); @@ -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() diff --git a/src/components/CommitTimeChart.tsx b/src/components/CommitTimeChart.tsx index 5076ccaa..5275470f 100644 --- a/src/components/CommitTimeChart.tsx +++ b/src/components/CommitTimeChart.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { BarChart, Bar, @@ -27,7 +27,7 @@ export default function CommitTimeChart() { const [days, setDays] = useState(30); const [peakTime, setPeakTime] = useState(null); - const fetchContributions = () => { + const fetchContributions = useCallback(() => { setLoading(true); setError(null); fetch(`/api/metrics/contributions?days=${days}`) @@ -81,11 +81,11 @@ export default function CommitTimeChart() { setError("We couldn't load your time-of-day data right now."), ) .finally(() => setLoading(false)); - }; + }, [days]); useEffect(() => { fetchContributions(); - }, [days]); + }, [fetchContributions]); return (
diff --git a/src/lib/badge-rate-limit.ts b/src/lib/badge-rate-limit.ts new file mode 100644 index 00000000..805b2276 --- /dev/null +++ b/src/lib/badge-rate-limit.ts @@ -0,0 +1,48 @@ +import { NextRequest } from "next/server"; + +const WINDOW_MS = 60 * 1000; +const BADGE_LIMIT = 20; + +const buckets = new Map(); + +export type BadgeRateLimitResult = { + allowed: boolean; + remaining: number; + reset: number; +}; + +function pruneBuckets(now: number) { + if (buckets.size < 500) return; + const cutoff = now - WINDOW_MS; + for (const [key, timestamps] of Array.from(buckets.entries())) { + if (timestamps.every((t) => t <= cutoff)) buckets.delete(key); + } +} + +export function checkBadgeRateLimit(ip: string): BadgeRateLimitResult { + const now = Date.now(); + pruneBuckets(now); + + const key = `badge:${ip}`; + const cutoff = now - WINDOW_MS; + const active = (buckets.get(key) ?? []).filter((t) => t > cutoff); + const reset = Math.ceil(((active[0] ?? now) + WINDOW_MS) / 1000); + + if (active.length >= BADGE_LIMIT) { + buckets.set(key, active); + return { allowed: false, remaining: 0, reset }; + } + + active.push(now); + buckets.set(key, active); + return { allowed: true, remaining: BADGE_LIMIT - active.length, reset }; +} + +export function getBadgeClientIp(req: NextRequest): string { + return ( + req.ip ?? + req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + req.headers.get("x-real-ip") ?? + "unknown" + ); +}