diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 6d7a07804..6bbe6bfc3 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -30,10 +30,10 @@ jobs: - uses: pnpm/action-setup@v3 with: - version: 9 + version: 11 - uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 22 cache: pnpm - name: Install app dependencies diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index c261bab33..e5eece0a1 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -44,12 +44,12 @@ jobs: - uses: pnpm/action-setup@v3 with: - version: 9 + version: 11 - name: Setup Node uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 22 cache: pnpm - name: Install dependencies diff --git a/src/app/api/goals/route.ts b/src/app/api/goals/route.ts index 0ed963e64..ca379d1e8 100644 --- a/src/app/api/goals/route.ts +++ b/src/app/api/goals/route.ts @@ -20,6 +20,7 @@ interface Goal { created_at: string; goal_reset_version: number; is_public: boolean; + category: string | null; } interface GoalHistory { @@ -34,6 +35,7 @@ interface GoalHistory { type Recurrence = "none" | "weekly" | "monthly"; const VALID_RECURRENCES = ["none", "weekly", "monthly"] as const; +const VALID_CATEGORIES = ["Side Project", "Work", "DSA", "Open Source"] as const; const MAX_TITLE_LEN = 100; const MAX_UNIT_LEN = 30; const MIN_TARGET = 1; @@ -201,7 +203,7 @@ try { return Response.json({ error: "Invalid request body" }, { status: 400 }); } - const { title, target, unit, recurrence, deadline } = body as Record; + const { title, target, unit, recurrence, deadline, category } = body as Record; if (typeof title !== "string" || title.trim().length === 0) { return Response.json({ error: "title must be a non-empty string" }, { status: 400 }); @@ -239,6 +241,11 @@ try { } } + const safeCategory = + typeof category === "string" && VALID_CATEGORIES.includes(category as any) + ? category + : null; + const user = await resolveAppUser(session.githubId, session.githubLogin); if (!user) return Response.json({ error: "User not found" }, { status: 404 }); @@ -287,6 +294,7 @@ try { deadline: safeDeadline, current: 0, goal_reset_version: 0, + category: safeCategory, }) .select() .single(); diff --git a/src/components/GoalTracker.tsx b/src/components/GoalTracker.tsx index 41c6b0fa4..3a855d2af 100644 --- a/src/components/GoalTracker.tsx +++ b/src/components/GoalTracker.tsx @@ -31,6 +31,7 @@ interface Goal { achieved: number; completed: boolean; } | null; + category?: string | null; } const RECURRENCE_LABELS: Record = { @@ -39,6 +40,15 @@ const RECURRENCE_LABELS: Record = { monthly: "Monthly", }; +export const CATEGORIES = ["Side Project", "Work", "DSA", "Open Source"]; + +export const CATEGORY_COLORS: Record = { + "Side Project": "bg-purple-500/10 text-purple-500 border-purple-500/30", + "Work": "bg-blue-500/10 text-blue-500 border-blue-500/30", + "DSA": "bg-emerald-500/10 text-emerald-500 border-emerald-500/30", + "Open Source": "bg-amber-500/10 text-amber-500 border-amber-500/30", +}; + export function useGoalTracker() { const [goals, setGoals] = useState([]); const [loading, setLoading] = useState(true); @@ -51,6 +61,7 @@ export function useGoalTracker() { const [unit, setUnit] = useState("commits"); const [recurrence, setRecurrence] = useState("none"); const [deadline, setDeadline] = useState(""); + const [category, setCategory] = useState(""); const [creating, setCreating] = useState(false); const [createError, setCreateError] = useState(null); const [confirmingId, setConfirmingId] = useState(null); @@ -161,7 +172,7 @@ export function useGoalTracker() { try { const result = await submitGoalWithRefresh({ - payload: { title, target, unit, recurrence, deadline: deadline || null }, + payload: { title, target, unit, recurrence, deadline: deadline || null, category: category || null }, handleSync, loadGoals, }); @@ -176,6 +187,7 @@ export function useGoalTracker() { setUnit("commits"); setRecurrence("none"); setDeadline(""); + setCategory(""); if (unit === "commits" || unit === "prs") { await handleSync(); @@ -293,6 +305,8 @@ export function useGoalTracker() { setRecurrence, deadline, setDeadline, + category, + setCategory, creating, createError, confirmingId, @@ -331,6 +345,8 @@ export default function GoalTracker() { setRecurrence, deadline, setDeadline, + category, + setCategory, creating, createError, confirmingId, @@ -347,6 +363,8 @@ export default function GoalTracker() { const { setSummary, setIsUpdating } = useDashboardWidgetA11y("goal-tracker"); + const [filterCategory, setFilterCategory] = useState("All"); + useEffect(() => { setIsUpdating(loading); }, [loading, setIsUpdating]); @@ -520,6 +538,35 @@ export default function GoalTracker() { )} + {/* Filter Toggle Pills */} + {goals.length > 0 && ( +
+ + {CATEGORIES.map((cat) => ( + + ))} +
+ )} + {goals.length === 0 ? (
- {goals.map((goal) => { + {goals + .filter((goal) => filterCategory === "All" || goal.category === filterCategory) + .map((goal) => { const pct = goal.current > 0 ? Math.max(1, Math.min(Math.round((goal.current / goal.target) * 100), 100)) @@ -577,6 +626,13 @@ export default function GoalTracker() { {RECURRENCE_LABELS[goal.recurrence]} )} + {goal.category && ( + + {goal.category} + + )} {isAutoSynced && ( +
+ + +
+ {(unit === "commits" || unit === "prs") && (

⚡ This goal will auto-update from your GitHub activity. diff --git a/src/lib/goal-tracker.ts b/src/lib/goal-tracker.ts index ec6215c71..6d6c4bba4 100644 --- a/src/lib/goal-tracker.ts +++ b/src/lib/goal-tracker.ts @@ -6,6 +6,7 @@ export interface CreateGoalPayload { unit: string; recurrence: Recurrence; deadline: string | null; + category?: string | null; } interface SubmitGoalOptions { diff --git a/src/lib/ssrf-protection.ts b/src/lib/ssrf-protection.ts index 3547600fd..c2f2638ab 100644 --- a/src/lib/ssrf-protection.ts +++ b/src/lib/ssrf-protection.ts @@ -9,7 +9,7 @@ const PRIVATE_RANGES = [ { start: 0xa9fe0000, end: 0xa9feffff }, ]; -function ipToNumber(ip: string): number { +export function ipToNumber(ip: string): number { const parts = ip.split("."); if (parts.length !== 4) return NaN; const numParts = parts.map(Number); @@ -17,7 +17,7 @@ function ipToNumber(ip: string): number { return ((numParts[0] << 24) | (numParts[1] << 16) | (numParts[2] << 8) | numParts[3]) >>> 0; } -function isPrivateIP(ip: string): boolean { +export function isPrivateIP(ip: string): boolean { ip = ip.toLowerCase(); // Extract IPv4 from IPv6-mapped IPv4 address diff --git a/supabase/migrations/20260622000000_add_goal_category.sql b/supabase/migrations/20260622000000_add_goal_category.sql new file mode 100644 index 000000000..0844f5950 --- /dev/null +++ b/supabase/migrations/20260622000000_add_goal_category.sql @@ -0,0 +1 @@ +ALTER TABLE goals ADD COLUMN category TEXT; diff --git a/test/ssrf-protection.test.ts b/test/ssrf-protection.test.ts index 42afba1da..2a400ac8d 100644 --- a/test/ssrf-protection.test.ts +++ b/test/ssrf-protection.test.ts @@ -1,112 +1,97 @@ -import { validateUrlBasic, isSafeUrl } from "../src/lib/ssrf-protection"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import dns from "dns/promises"; +import { ipToNumber, isPrivateIP, validateUrlBasic } from "../src/lib/ssrf-protection"; +import { describe, it, expect } from "vitest"; + +describe("ssrf-protection pure utility functions", () => { + describe("ipToNumber", () => { + it("should convert valid IPv4 addresses to numbers", () => { + expect(ipToNumber("0.0.0.0")).toBe(0); + expect(ipToNumber("255.255.255.255")).toBe(4294967295); + expect(ipToNumber("192.168.1.1")).toBe(3232235777); + }); + + it("should return NaN for invalid formats (non-numeric parts, out-of-range values, wrong octet count)", () => { + // non-numeric parts + expect(ipToNumber("192.168.1.a")).toBeNaN(); + expect(ipToNumber("not.an.ip.address")).toBeNaN(); + // out-of-range values + expect(ipToNumber("192.168.1.256")).toBeNaN(); + expect(ipToNumber("192.-1.1.1")).toBeNaN(); + // wrong octet count + expect(ipToNumber("192.168.1")).toBeNaN(); + expect(ipToNumber("192.168.1.1.1")).toBeNaN(); + // other invalid formats + expect(ipToNumber("")).toBeNaN(); + }); + }); -vi.mock("dns/promises", () => ({ - default: { - lookup: vi.fn(), - }, -})); + describe("isPrivateIP", () => { + it("should return true for all five private IPv4 ranges", () => { + // 10.0.0.0/8 + expect(isPrivateIP("10.0.0.0")).toBe(true); + expect(isPrivateIP("10.255.255.255")).toBe(true); + // 172.16.0.0/12 + expect(isPrivateIP("172.16.0.0")).toBe(true); + expect(isPrivateIP("172.31.255.255")).toBe(true); + // 192.168.0.0/16 + expect(isPrivateIP("192.168.0.0")).toBe(true); + expect(isPrivateIP("192.168.255.255")).toBe(true); + // 127.0.0.0/8 + expect(isPrivateIP("127.0.0.0")).toBe(true); + expect(isPrivateIP("127.255.255.255")).toBe(true); + expect(isPrivateIP("127.0.0.1")).toBe(true); + // 169.254.0.0/16 + expect(isPrivateIP("169.254.0.0")).toBe(true); + expect(isPrivateIP("169.254.255.255")).toBe(true); + }); + + it("should return true for IPv6 loopback and link-local addresses", () => { + expect(isPrivateIP("::1")).toBe(true); + expect(isPrivateIP("::")).toBe(true); + expect(isPrivateIP("fe80::1")).toBe(true); + expect(isPrivateIP("fc00::1")).toBe(true); + expect(isPrivateIP("fd00::1")).toBe(true); + }); + + it("should handle IPv6-mapped IPv4 addresses correctly", () => { + // mapped private + expect(isPrivateIP("::ffff:127.0.0.1")).toBe(true); + expect(isPrivateIP("::ffff:192.168.1.1")).toBe(true); + // mapped public + expect(isPrivateIP("::ffff:8.8.8.8")).toBe(false); + }); + + it("should verify public IPs are not flagged", () => { + expect(isPrivateIP("8.8.8.8")).toBe(false); + expect(isPrivateIP("1.1.1.1")).toBe(false); + // Just outside private ranges + expect(isPrivateIP("172.32.0.0")).toBe(false); + expect(isPrivateIP("192.169.0.0")).toBe(false); + expect(isPrivateIP("9.255.255.255")).toBe(false); + expect(isPrivateIP("11.0.0.0")).toBe(false); + // Public IPv6 + expect(isPrivateIP("2001:4860:4860::8888")).toBe(false); + }); + }); -describe("ssrf-protection", () => { describe("validateUrlBasic", () => { - it("should return true for valid http URL", () => { + it("should return true for http and https URLs", () => { expect(validateUrlBasic("http://example.com")).toBe(true); - }); - - it("should return true for valid https URL", () => { expect(validateUrlBasic("https://example.com")).toBe(true); + expect(validateUrlBasic("http://example.com:8080/path?query=1#hash")).toBe(true); }); - it("should return true for https URL with port", () => { - expect(validateUrlBasic("https://example.com:8080")).toBe(true); - }); - - it("should return true for http URL with path", () => { - expect(validateUrlBasic("http://example.com/path/to/resource")).toBe(true); - }); - - it("should return false for invalid protocol", () => { + it("should return false for other protocols (ftp, data:, javascript:)", () => { expect(validateUrlBasic("ftp://example.com")).toBe(false); + expect(validateUrlBasic("data:text/plain;base64,SGVsbG8=")).toBe(false); + expect(validateUrlBasic("javascript:alert(1)")).toBe(false); expect(validateUrlBasic("file:///etc/passwd")).toBe(false); - expect(validateUrlBasic("ssh://example.com")).toBe(false); }); - it("should return false for malformed URL", () => { + it("should return false for malformed URLs", () => { expect(validateUrlBasic("not-a-url")).toBe(false); - expect(validateUrlBasic("")).toBe(false); expect(validateUrlBasic("://example.com")).toBe(false); - }); - - it("should return false for URL with no protocol", () => { + expect(validateUrlBasic("")).toBe(false); expect(validateUrlBasic("example.com")).toBe(false); - expect(validateUrlBasic("example.com/path")).toBe(false); - }); - - it("should return false for data URL", () => { - expect(validateUrlBasic("data:text/html,")).toBe(false); - }); - - it("should return false for javascript URL", () => { - expect(validateUrlBasic("javascript:alert(1)")).toBe(false); - }); - - it("should handle URLs with query parameters", () => { - expect(validateUrlBasic("https://example.com?foo=bar")).toBe(true); - expect(validateUrlBasic("https://example.com/path?foo=bar&baz=qux")).toBe(true); - }); - - it("should handle URLs with fragments", () => { - expect(validateUrlBasic("https://example.com#section")).toBe(true); - expect(validateUrlBasic("https://example.com/path#section")).toBe(true); - }); - }); - - describe("isSafeUrl", () => { - const mockLookup = dns.lookup as any; - - beforeEach(() => { - mockLookup.mockReset(); - }); - - it("should return false for invalid protocol", async () => { - expect(await isSafeUrl("ftp://example.com")).toBe(false); - }); - - it("should return false for localhost and 0.0.0.0 bypasses", async () => { - expect(await isSafeUrl("http://localhost")).toBe(false); - expect(await isSafeUrl("http://0.0.0.0")).toBe(false); - expect(await isSafeUrl("http://[::1]")).toBe(false); - }); - - it("should return true for public IP literals directly without DNS", async () => { - expect(await isSafeUrl("http://8.8.8.8")).toBe(true); - expect(await isSafeUrl("http://[2001:4860:4860::8888]")).toBe(true); - }); - - it("should return true for public IPs via DNS", async () => { - mockLookup.mockResolvedValue([{ address: "8.8.8.8", family: 4 }]); - expect(await isSafeUrl("http://example.com")).toBe(true); - }); - - it("should return false for private IPv4", async () => { - mockLookup.mockResolvedValue([{ address: "10.0.0.1", family: 4 }]); - expect(await isSafeUrl("http://internal.com")).toBe(false); - }); - - it("should return false for IPv6-mapped IPv4 private address", async () => { - mockLookup.mockResolvedValue([{ address: "::ffff:192.168.1.1", family: 6 }]); - expect(await isSafeUrl("http://internal.com")).toBe(false); - }); - - it("should return false for IPv6 loopback and link-local", async () => { - mockLookup.mockResolvedValue([{ address: "fe80::1", family: 6 }]); - expect(await isSafeUrl("http://internal.com")).toBe(false); - }); - - it("should return true for public IPv6", async () => { - mockLookup.mockResolvedValue([{ address: "2001:4860:4860::8888", family: 6 }]); - expect(await isSafeUrl("http://example.com")).toBe(true); }); }); });