diff --git a/package.json b/package.json index 7a4549cba..252541b82 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,8 @@ "sharp": "^0.35.1", "sonner": "^2.0.7", "swagger-ui-react": "5.32.6", - "tailwind-merge": "^3.6.0" + "tailwind-merge": "^3.6.0", + "xlsx": "^0.18.5" }, "devDependencies": { "@emnapi/core": "^1.11.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69cd44ce1..397ae62e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,6 +137,9 @@ importers: tailwind-merge: specifier: ^3.6.0 version: 3.6.0 + xlsx: + specifier: ^0.18.5 + version: 0.18.5 devDependencies: '@emnapi/core': specifier: ^1.11.0 @@ -3094,6 +3097,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + adler-32@1.3.1: + resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} + engines: {node: '>=0.8'} + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -3430,6 +3437,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + cfb@1.2.2: + resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} + engines: {node: '>=0.8'} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -3496,6 +3507,10 @@ packages: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + codepage@1.15.0: + resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} + engines: {node: '>=0.8'} + collect-v8-coverage@1.0.3: resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} @@ -3553,6 +3568,11 @@ packages: core-js@3.49.0: resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -4136,6 +4156,10 @@ packages: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} + frac@1.1.2: + resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} + engines: {node: '>=0.8'} + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -5995,6 +6019,10 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + ssf@0.11.2: + resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} + engines: {node: '>=0.8'} + stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} @@ -6709,10 +6737,18 @@ packages: engines: {node: '>=8'} hasBin: true + wmf@1.0.2: + resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} + engines: {node: '>=0.8'} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + word@0.3.0: + resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} + engines: {node: '>=0.8'} + wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} @@ -6847,6 +6883,11 @@ packages: resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + xlsx@0.18.5: + resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} + engines: {node: '>=0.8'} + hasBin: true + xml-but-prettier@1.0.1: resolution: {integrity: sha512-C2CJaadHrZTqESlH03WOyw0oZTtoy2uEg6dSDF6YRg+9GnYNub53RRemLpnvtbHDFelxMx4LajiFsYeR6XJHgQ==} @@ -10064,6 +10105,8 @@ snapshots: acorn@8.17.0: {} + adler-32@1.3.1: {} + agent-base@6.0.2: dependencies: debug: 4.4.3 @@ -10455,6 +10498,11 @@ snapshots: ccount@2.0.1: {} + cfb@1.2.2: + dependencies: + adler-32: 1.3.1 + crc-32: 1.2.2 + chai@6.2.2: {} chalk@4.1.2: @@ -10513,6 +10561,8 @@ snapshots: co@4.6.0: {} + codepage@1.15.0: {} + collect-v8-coverage@1.0.3: {} color-convert@2.0.1: @@ -10556,6 +10606,8 @@ snapshots: core-js@3.49.0: optional: true + crc-32@1.2.2: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -11289,6 +11341,8 @@ snapshots: format@0.2.2: {} + frac@1.1.2: {} + fraction.js@5.3.4: {} fs-extra@9.1.0: @@ -13610,6 +13664,10 @@ snapshots: sprintf-js@1.0.3: {} + ssf@0.11.2: + dependencies: + frac: 1.1.2 + stable-hash@0.0.5: {} stack-utils@2.0.6: @@ -14463,8 +14521,12 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + wmf@1.0.2: {} + word-wrap@1.2.5: {} + word@0.3.0: {} + wordwrap@1.0.0: {} workbox-background-sync@6.6.0: @@ -14779,6 +14841,16 @@ snapshots: imurmurhash: 0.1.4 signal-exit: 4.1.0 + xlsx@0.18.5: + dependencies: + adler-32: 1.3.1 + cfb: 1.2.2 + codepage: 1.15.0 + crc-32: 1.2.2 + ssf: 0.11.2 + wmf: 1.0.2 + word: 0.3.0 + xml-but-prettier@1.0.1: dependencies: repeat-string: 1.6.1 diff --git a/scratch_fix_api.py b/scratch_fix_api.py new file mode 100644 index 000000000..f63daf36f --- /dev/null +++ b/scratch_fix_api.py @@ -0,0 +1,24 @@ +import glob + +files = glob.glob("src/app/api/milestones/**/route.ts", recursive=True) + glob.glob("src/app/api/tasks/**/route.ts", recursive=True) + +for file in files: + with open(file, "r") as f: + content = f.read() + + # Replace the session checks + content = content.replace( + "if (!session?.user?.id && !session?.githubId) {\n return new Response(\"Unauthorized\", { status: 401 });\n }", + "if (!session?.githubId) {\n return new Response(\"Unauthorized\", { status: 401 });\n }" + ) + + # Replace the user id retrieval + content = content.replace( + "const userId = session.user?.id || session.githubId;\n const appUser = await resolveAppUser(userId, session.githubLogin);", + "const appUser = await resolveAppUser(session.githubId, session.githubLogin);" + ) + + with open(file, "w") as f: + f.write(content) + + print("Fixed", file) diff --git a/scratch_fix_schema.py b/scratch_fix_schema.py new file mode 100644 index 000000000..9a88215b1 --- /dev/null +++ b/scratch_fix_schema.py @@ -0,0 +1,21 @@ +import os + +filepath = "supabase/schema.sql" +with open(filepath, "r") as f: + lines = f.readlines() + +new_lines = [] +for line in lines: + if line.startswith("<<<<<<< HEAD"): + continue + elif line.startswith("======="): + continue + elif line.startswith(">>>>>>> upstream/main"): + continue + else: + new_lines.append(line) + +with open(filepath, "w") as f: + f.writelines(new_lines) + +print("Conflict markers removed from", filepath) diff --git a/scratch_update_schema.py b/scratch_update_schema.py new file mode 100644 index 000000000..94152fc93 --- /dev/null +++ b/scratch_update_schema.py @@ -0,0 +1,37 @@ +import sys + +filepath = "supabase/schema.sql" +with open(filepath, "r") as f: + content = f.read() + +old_tasks = """create table if not exists tasks ( + id text primary key default gen_random_uuid()::text, + user_id text not null references users(id) on delete cascade, + title text not null, + completed boolean not null default false, + milestone_id text, + created_at timestamptz default now(), + updated_at timestamptz default now() +);""" + +new_tasks = """create table if not exists tasks ( + id text primary key default gen_random_uuid()::text, + user_id text not null references users(id) on delete cascade, + title text not null, + completed boolean not null default false, + status text not null default 'todo', + priority text not null default 'medium', + due_date timestamptz, + tags text[] not null default '{}'::text[], + milestone_id text, + created_at timestamptz default now(), + updated_at timestamptz default now() +);""" + +if old_tasks in content: + content = content.replace(old_tasks, new_tasks) + with open(filepath, "w") as f: + f.write(content) + print("Updated schema.sql successfully") +else: + print("Could not find the target text in schema.sql") diff --git a/src/app/api/milestones/[id]/route.ts b/src/app/api/milestones/[id]/route.ts index a905293b9..f24ebb930 100644 --- a/src/app/api/milestones/[id]/route.ts +++ b/src/app/api/milestones/[id]/route.ts @@ -2,73 +2,85 @@ import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { supabaseAdmin } from "@/lib/supabase"; import { resolveAppUser } from "@/lib/resolve-user"; -import { NextResponse } from "next/server"; +import { stripHtml } from "@/lib/sanitize"; export const dynamic = "force-dynamic"; -export async function PATCH( +export async function PUT( req: Request, { params }: { params: Promise<{ id: string }> } ) { const { id } = await params; const session = await getServerSession(authOptions); if (!session?.githubId) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + return new Response("Unauthorized", { status: 401 }); } - const user = await resolveAppUser(session.githubId, session.githubLogin); - if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 }); + const appUser = await resolveAppUser(session.githubId, session.githubLogin); + if (!appUser) return new Response("User not found", { status: 404 }); - let body: unknown; try { - body = await req.json(); - } catch { - return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + const body = await req.json(); + const name = stripHtml(body.name || "").trim(); + const description = stripHtml(body.description || "").trim(); + const dueDate = body.dueDate || null; + const taskIds = body.taskIds || []; + + if (!name) { + return new Response("Name is required", { status: 400 }); + } + + const { data: existing } = await supabaseAdmin + .from("milestones") + .select("id") + .eq("id", id) + .eq("user_id", appUser.id) + .single(); + + if (!existing) { + return new Response("Milestone not found", { status: 404 }); + } + + const { data: milestone, error } = await supabaseAdmin + .from("milestones") + .update({ + name, + description, + due_date: dueDate, + updated_at: new Date().toISOString(), + }) + .eq("id", id) + .eq("user_id", appUser.id) + .select() + .single(); + + if (error) throw error; + + // First unlink any tasks currently linked to this milestone + const { error: unlinkError } = await supabaseAdmin + .from("tasks") + .update({ milestone_id: null }) + .eq("milestone_id", id) + .eq("user_id", appUser.id); + if (unlinkError) throw unlinkError; + + // Link the new tasks + if (taskIds.length > 0) { + const { error: linkError } = await supabaseAdmin + .from("tasks") + .update({ milestone_id: id }) + .in("id", taskIds) + .eq("user_id", appUser.id); + if (linkError) throw linkError; + } + + return new Response(JSON.stringify(milestone), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (err: any) { + return new Response(err.message, { status: 500 }); } - - if (typeof body !== "object" || body === null) { - return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); - } - - const { currentValue } = body as Record; - - if ( - typeof currentValue !== "number" || - !Number.isInteger(currentValue) || - currentValue < 0 - ) { - return NextResponse.json( - { error: "currentValue must be a non-negative integer" }, - { status: 400 } - ); - } - - const { data: existing } = await supabaseAdmin - .from("milestones") - .select("target_value") - .eq("id", id) - .eq("user_id", user.id) - .single(); - - if (!existing) { - return NextResponse.json({ error: "Milestone not found" }, { status: 404 }); - } - - const safeCurrentValue = Math.min(currentValue, existing.target_value); - - const { data, error } = await supabaseAdmin - .from("milestones") - .update({ current_value: safeCurrentValue }) - .eq("id", id) - .eq("user_id", user.id) - .select() - .single(); - - if (error) { - return NextResponse.json({ error: "Failed to update milestone" }, { status: 500 }); - } - - return NextResponse.json({ milestone: data }); } export async function DELETE( @@ -78,21 +90,24 @@ export async function DELETE( const { id } = await params; const session = await getServerSession(authOptions); if (!session?.githubId) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + return new Response("Unauthorized", { status: 401 }); } - const user = await resolveAppUser(session.githubId, session.githubLogin); - if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 }); + const appUser = await resolveAppUser(session.githubId, session.githubLogin); + if (!appUser) return new Response("User not found", { status: 404 }); const { error } = await supabaseAdmin .from("milestones") .delete() .eq("id", id) - .eq("user_id", user.id); + .eq("user_id", appUser.id); if (error) { - return NextResponse.json({ error: "Failed to delete milestone" }, { status: 500 }); + return new Response(error.message, { status: 500 }); } - return NextResponse.json({ success: true }); + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); } diff --git a/src/app/api/milestones/route.ts b/src/app/api/milestones/route.ts index 4019a3d7f..0733f42f1 100644 --- a/src/app/api/milestones/route.ts +++ b/src/app/api/milestones/route.ts @@ -3,136 +3,111 @@ import { authOptions } from "@/lib/auth"; import { supabaseAdmin } from "@/lib/supabase"; import { resolveAppUser } from "@/lib/resolve-user"; import { stripHtml } from "@/lib/sanitize"; -import { NextResponse } from "next/server"; export const dynamic = "force-dynamic"; -const MAX_MILESTONES_PER_USER = 20; -const MAX_TITLE_LEN = 100; -const MAX_DESCRIPTION_LEN = 300; -const MAX_UNIT_LEN = 30; -const VALID_CATEGORIES = ["commits", "streak", "projects", "custom"] as const; -type Category = (typeof VALID_CATEGORIES)[number]; - export async function GET() { const session = await getServerSession(authOptions); if (!session?.githubId) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + return new Response("Unauthorized", { status: 401 }); } - const user = await resolveAppUser(session.githubId, session.githubLogin); - if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 }); + const appUser = await resolveAppUser(session.githubId, session.githubLogin); + if (!appUser) { + return new Response("User not found", { status: 404 }); + } - const { data, error } = await supabaseAdmin + const { data: milestones, error } = await supabaseAdmin .from("milestones") - .select("*") - .eq("user_id", user.id) - .order("created_at", { ascending: false }) - .limit(MAX_MILESTONES_PER_USER); + .select("id, name, description, due_date") + .eq("user_id", appUser.id) + .order("created_at", { ascending: false }); if (error) { - return NextResponse.json({ error: "Failed to fetch milestones" }, { status: 500 }); + return new Response(error.message, { status: 500 }); } - return NextResponse.json({ milestones: data ?? [] }); + // Fetch tasks to calculate taskIds and completion status per milestone + const { data: tasks, error: taskError } = await supabaseAdmin + .from("tasks") + .select("id, milestone_id, completed") + .eq("user_id", appUser.id) + .not("milestone_id", "is", null); + + if (taskError) { + return new Response(taskError.message, { status: 500 }); + } + + const result = milestones.map((m) => { + const mTasks = tasks.filter((t) => t.milestone_id === m.id); + return { + id: m.id, + name: m.name, + description: m.description, + dueDate: m.due_date, + taskIds: mTasks.map((t) => t.id), + totalTasks: mTasks.length, + completedTasks: mTasks.filter((t) => t.completed).length, + }; + }); + + return new Response(JSON.stringify(result), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); } export async function POST(req: Request) { const session = await getServerSession(authOptions); if (!session?.githubId) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - let body: unknown; - try { - body = await req.json(); - } catch { - return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); - } - - if (typeof body !== "object" || body === null) { - return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); - } - - const { title, description, targetValue, currentValue, unit, targetDate, category } = - body as Record; - - if (typeof title !== "string" || title.trim().length === 0) { - return NextResponse.json({ error: "title must be a non-empty string" }, { status: 400 }); - } - const safeTitle = stripHtml(title).slice(0, MAX_TITLE_LEN); - if (safeTitle.length === 0) { - return NextResponse.json({ error: "title must not be empty" }, { status: 400 }); + return new Response("Unauthorized", { status: 401 }); } - const safeDescription = - typeof description === "string" - ? stripHtml(description).slice(0, MAX_DESCRIPTION_LEN) - : ""; - - if ( - typeof targetValue !== "number" || - !Number.isInteger(targetValue) || - targetValue < 1 || - targetValue > 1_000_000 - ) { - return NextResponse.json( - { error: "targetValue must be an integer between 1 and 1,000,000" }, - { status: 400 } - ); + const appUser = await resolveAppUser(session.githubId, session.githubLogin); + if (!appUser) { + return new Response("User not found", { status: 404 }); } - const safeCurrentValue = - typeof currentValue === "number" && Number.isInteger(currentValue) && currentValue >= 0 - ? Math.min(currentValue, targetValue) - : 0; - - const safeUnit = - typeof unit === "string" && unit.trim().length > 0 - ? unit.trim().slice(0, MAX_UNIT_LEN) - : "units"; - - if (typeof targetDate !== "string" || isNaN(new Date(targetDate).getTime())) { - return NextResponse.json({ error: "targetDate must be a valid date string" }, { status: 400 }); - } - - const safeCategory: Category = VALID_CATEGORIES.includes(category as Category) - ? (category as Category) - : "custom"; - - const user = await resolveAppUser(session.githubId, session.githubLogin); - if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 }); - - const { count } = await supabaseAdmin - .from("milestones") - .select("*", { count: "exact", head: true }) - .eq("user_id", user.id); - - if ((count ?? 0) >= MAX_MILESTONES_PER_USER) { - return NextResponse.json( - { error: `You can have at most ${MAX_MILESTONES_PER_USER} milestones.` }, - { status: 400 } - ); - } - - const { data, error } = await supabaseAdmin - .from("milestones") - .insert({ - user_id: user.id, - title: safeTitle, - description: safeDescription, - target_value: targetValue, - current_value: safeCurrentValue, - unit: safeUnit, - target_date: targetDate, - category: safeCategory, - }) - .select() - .single(); - - if (error) { - return NextResponse.json({ error: "Failed to create milestone" }, { status: 500 }); + try { + const body = await req.json(); + const name = stripHtml(body.name || "").trim(); + const description = stripHtml(body.description || "").trim(); + const dueDate = body.dueDate || null; + const taskIds = body.taskIds || []; + + if (!name) { + return new Response("Name is required", { status: 400 }); + } + + const { data: milestone, error } = await supabaseAdmin + .from("milestones") + .insert({ + user_id: appUser.id, + name, + description, + due_date: dueDate, + }) + .select() + .single(); + + if (error) throw error; + + // Link tasks if any + if (taskIds.length > 0) { + const { error: updateError } = await supabaseAdmin + .from("tasks") + .update({ milestone_id: milestone.id }) + .in("id", taskIds) + .eq("user_id", appUser.id); + + if (updateError) throw updateError; + } + + return new Response(JSON.stringify(milestone), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + } catch (err: any) { + return new Response(err.message, { status: 500 }); } - - return NextResponse.json({ milestone: data }, { status: 201 }); } diff --git a/src/app/api/tasks/[id]/route.ts b/src/app/api/tasks/[id]/route.ts new file mode 100644 index 000000000..ea5ace8b0 --- /dev/null +++ b/src/app/api/tasks/[id]/route.ts @@ -0,0 +1,108 @@ +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; +import { resolveAppUser } from "@/lib/resolve-user"; +import { stripHtml } from "@/lib/sanitize"; + +export const dynamic = "force-dynamic"; + +export async function PUT( + req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return new Response("Unauthorized", { status: 401 }); + } + + const appUser = await resolveAppUser(session.githubId, session.githubLogin); + if (!appUser) return new Response("User not found", { status: 404 }); + + try { + const body = await req.json(); + + const updates: any = {}; + if (body.title !== undefined) { + updates.title = stripHtml(body.title).trim(); + if (!updates.title) return new Response("Title cannot be empty", { status: 400 }); + } + if (body.completed !== undefined) { + updates.completed = Boolean(body.completed); + } + if (body.status !== undefined) { + updates.status = stripHtml(body.status).trim(); + } + if (body.priority !== undefined) { + updates.priority = stripHtml(body.priority).trim(); + } + if (body.dueDate !== undefined) { + updates.due_date = body.dueDate; + } + if (body.tags !== undefined && Array.isArray(body.tags)) { + updates.tags = body.tags.map((t: string) => stripHtml(t).trim()); + } + if (body.milestoneId !== undefined) { + updates.milestone_id = body.milestoneId; + } + + updates.updated_at = new Date().toISOString(); + + const { data: existing } = await supabaseAdmin + .from("tasks") + .select("id") + .eq("id", id) + .eq("user_id", appUser.id) + .single(); + + if (!existing) { + return new Response("Task not found", { status: 404 }); + } + + const { data: task, error } = await supabaseAdmin + .from("tasks") + .update(updates) + .eq("id", id) + .eq("user_id", appUser.id) + .select() + .single(); + + if (error) throw error; + + return new Response(JSON.stringify(task), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (err: any) { + return new Response(err.message, { status: 500 }); + } +} + +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return new Response("Unauthorized", { status: 401 }); + } + + const appUser = await resolveAppUser(session.githubId, session.githubLogin); + if (!appUser) return new Response("User not found", { status: 404 }); + + const { error } = await supabaseAdmin + .from("tasks") + .delete() + .eq("id", id) + .eq("user_id", appUser.id); + + if (error) { + return new Response(error.message, { status: 500 }); + } + + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts new file mode 100644 index 000000000..8949ac66c --- /dev/null +++ b/src/app/api/tasks/route.ts @@ -0,0 +1,84 @@ +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; +import { resolveAppUser } from "@/lib/resolve-user"; +import { stripHtml } from "@/lib/sanitize"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return new Response("Unauthorized", { status: 401 }); + } + + const appUser = await resolveAppUser(session.githubId, session.githubLogin); + if (!appUser) { + return new Response("User not found", { status: 404 }); + } + + const { data: tasks, error } = await supabaseAdmin + .from("tasks") + .select("*") + .eq("user_id", appUser.id) + .order("created_at", { ascending: false }); + + if (error) { + return new Response(error.message, { status: 500 }); + } + + return new Response(JSON.stringify(tasks), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +export async function POST(req: Request) { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return new Response("Unauthorized", { status: 401 }); + } + + const appUser = await resolveAppUser(session.githubId, session.githubLogin); + if (!appUser) { + return new Response("User not found", { status: 404 }); + } + + try { + const body = await req.json(); + const title = stripHtml(body.title || "").trim(); + const milestone_id = body.milestoneId || null; + const status = stripHtml(body.status || "todo").trim(); + const priority = stripHtml(body.priority || "medium").trim(); + const due_date = body.dueDate || null; + const tags = Array.isArray(body.tags) ? body.tags.map((t: string) => stripHtml(t).trim()) : []; + + if (!title) { + return new Response("Title is required", { status: 400 }); + } + + const { data: task, error } = await supabaseAdmin + .from("tasks") + .insert({ + user_id: appUser.id, + title, + milestone_id, + completed: false, + status, + priority, + due_date, + tags + }) + .select() + .single(); + + if (error) throw error; + + return new Response(JSON.stringify(task), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + } catch (err: any) { + return new Response(err.message, { status: 500 }); + } +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 24986fa52..03af0eb46 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -5,6 +5,7 @@ import MilestonePlanner from "@/components/MilestonePlanner"; import TodayFocusHero from "@/components/TodayFocusHero"; import DashboardHeader from "@/components/DashboardHeader"; import ExportButton from "@/components/ExportButton"; +import TaskExportButton from "@/components/TaskExportButton"; import Link from "next/link"; import { ChevronRight } from "lucide-react"; import { authOptions } from "@/lib/auth"; @@ -77,8 +78,9 @@ export default async function DashboardPage() { > Settings -
+
+
diff --git a/src/components/ProjectMilestones.tsx b/src/components/ProjectMilestones.tsx new file mode 100644 index 000000000..943370a0e --- /dev/null +++ b/src/components/ProjectMilestones.tsx @@ -0,0 +1,269 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Target, Plus, Trash2, CheckCircle2, Circle } from 'lucide-react'; +import { Milestone, Task } from '@/types/project-milestone'; + +export default function ProjectMilestones() { + const [milestones, setMilestones] = useState([]); + const [tasks, setTasks] = useState([]); + const [isClient, setIsClient] = useState(false); + const [loading, setLoading] = useState(true); + + const [showMilestoneForm, setShowMilestoneForm] = useState(false); + const [milestoneForm, setMilestoneForm] = useState({ name: '', description: '', dueDate: '' }); + + const [taskInputs, setTaskInputs] = useState>({}); + + useEffect(() => { + setIsClient(true); + Promise.all([ + fetch('/api/milestones').then(r => r.json()), + fetch('/api/tasks').then(r => r.json()) + ]).then(([mils, tsks]) => { + setMilestones(Array.isArray(mils) ? mils : []); + setTasks(Array.isArray(tsks) ? tsks : []); + setLoading(false); + }).catch(e => { + console.error(e); + setLoading(false); + }); + }, []); + + if (!isClient) return null; + + const handleCreateMilestone = async () => { + if (!milestoneForm.name || !milestoneForm.dueDate) return; + + const res = await fetch('/api/milestones', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: milestoneForm.name, + description: milestoneForm.description, + dueDate: milestoneForm.dueDate, + taskIds: [] + }) + }); + + if (res.ok) { + const newMilestone = await res.json(); + setMilestones(prev => [newMilestone, ...prev]); + setMilestoneForm({ name: '', description: '', dueDate: '' }); + setShowMilestoneForm(false); + } + }; + + const handleDeleteMilestone = async (id: string) => { + const res = await fetch(`/api/milestones/${id}`, { method: 'DELETE' }); + if (res.ok) { + setMilestones(prev => prev.filter(m => m.id !== id)); + } + }; + + const handleAddTask = async (milestoneId: string) => { + const title = taskInputs[milestoneId]?.trim(); + if (!title) return; + + const res = await fetch('/api/tasks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title, milestoneId }) + }); + + if (res.ok) { + const newTask = await res.json(); + setTasks(prev => [...prev, newTask]); + setMilestones(prev => prev.map(m => { + if (m.id === milestoneId) { + return { ...m, taskIds: [...(m.taskIds || []), newTask.id] }; + } + return m; + })); + setTaskInputs(prev => ({ ...prev, [milestoneId]: '' })); + } + }; + + const handleToggleTask = async (taskId: string, currentCompleted: boolean) => { + setTasks(prev => prev.map(t => t.id === taskId ? { ...t, completed: !currentCompleted } : t)); + + const res = await fetch(`/api/tasks/${taskId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ completed: !currentCompleted }) + }); + + if (!res.ok) { + setTasks(prev => prev.map(t => t.id === taskId ? { ...t, completed: currentCompleted } : t)); + } + }; + + const handleDeleteTask = async (milestoneId: string, taskId: string) => { + const res = await fetch(`/api/tasks/${taskId}`, { method: 'DELETE' }); + if (res.ok) { + setTasks(prev => prev.filter(t => t.id !== taskId)); + setMilestones(prev => prev.map(m => { + if (m.id === milestoneId) { + return { ...m, taskIds: (m.taskIds || []).filter(id => id !== taskId) }; + } + return m; + })); + } + }; + + return ( +
+
+
+ +

+ Project Milestones +

+
+ +
+ + {showMilestoneForm && ( +
+
+
+ + setMilestoneForm(f => ({ ...f, name: e.target.value }))} + placeholder="e.g. v1.0 Release" + style={{ width: '100%', padding: '8px 10px', borderRadius: '8px', border: '1px solid var(--border)', background: 'var(--background)', color: 'var(--foreground)', fontSize: '0.875rem' }} + /> +
+
+ + setMilestoneForm(f => ({ ...f, description: e.target.value }))} + placeholder="Optional description" + style={{ width: '100%', padding: '8px 10px', borderRadius: '8px', border: '1px solid var(--border)', background: 'var(--background)', color: 'var(--foreground)', fontSize: '0.875rem' }} + /> +
+
+ + setMilestoneForm(f => ({ ...f, dueDate: e.target.value }))} + style={{ width: '100%', padding: '8px 10px', borderRadius: '8px', border: '1px solid var(--border)', background: 'var(--background)', color: 'var(--foreground)', fontSize: '0.875rem' }} + /> +
+
+
+ + +
+
+ )} + + {loading ? ( +
+ Loading milestones... +
+ ) : milestones.length === 0 ? ( +
+ +

No project milestones yet. Create one to start tracking!

+
+ ) : ( +
+ {milestones.map(m => { + const mTasks = (m.taskIds || []).map(id => tasks.find(t => t.id === id)).filter((t): t is Task => !!t); + const totalTasks = mTasks.length; + const completedTasks = mTasks.filter(t => t.completed).length; + const progress = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0; + + return ( +
+
+
+

{m.name}

+ {m.description &&

{m.description}

} + Due: {new Date(m.dueDate).toLocaleDateString()} +
+ +
+ +
+
+ Progress + {progress}% ({completedTasks}/{totalTasks}) +
+
+
+
+
+ +
+

Tasks

+ {mTasks.length === 0 ? ( +

No tasks linked.

+ ) : ( +
    + {mTasks.map(task => ( +
  • +
    handleToggleTask(task.id, task.completed)}> + {task.completed ? : } + {task.title} +
    + +
  • + ))} +
+ )} + +
+ setTaskInputs(prev => ({ ...prev, [m.id]: e.target.value }))} + onKeyDown={e => { + if (e.key === 'Enter') handleAddTask(m.id); + }} + style={{ flex: 1, padding: '6px 10px', borderRadius: '6px', border: '1px solid var(--border)', background: 'var(--background)', color: 'var(--foreground)', fontSize: '0.8rem' }} + /> + +
+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/components/TaskExportButton.tsx b/src/components/TaskExportButton.tsx new file mode 100644 index 000000000..d0f97188f --- /dev/null +++ b/src/components/TaskExportButton.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useState } from "react"; +import { Download } from "lucide-react"; +import { Task } from "@/types/project-milestone"; + +export default function TaskExportButton() { + const [loading, setLoading] = useState(false); + + const fetchTasks = async (): Promise => { + const res = await fetch("/api/tasks"); + if (!res.ok) throw new Error("Failed to fetch tasks"); + return res.json(); + }; + + const handleExportCSV = async () => { + try { + setLoading(true); + const tasks = await fetchTasks(); + + const header = ["id", "title", "status", "priority", "due_date", "created_at", "tags"].join(","); + const rows = tasks.map(t => { + // Handle special characters in title by wrapping in quotes and escaping internal quotes + const title = `"${(t.title || "").replace(/"/g, '""')}"`; + const status = t.status || (t.completed ? "done" : "todo"); + const priority = t.priority || "medium"; + const dueDate = t.dueDate || ""; + // Assume t as any since created_at might be returned from API + const createdAt = (t as any).created_at || ""; + const tags = `"${(t.tags || []).join(";")}"`; + + return [t.id, title, status, priority, dueDate, createdAt, tags].join(","); + }); + + const csvContent = [header, ...rows].join("\n"); + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.setAttribute("download", `devtrack_tasks_${new Date().toISOString().split("T")[0]}.csv`); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } catch (err) { + console.error(err); + alert("Failed to export tasks to CSV."); + } finally { + setLoading(false); + } + }; + + const handleExportExcel = async () => { + try { + setLoading(true); + const tasks = await fetchTasks(); + + // Dynamic import to keep bundle size small if not used + const XLSX = await import("xlsx"); + + const data = tasks.map(t => ({ + ID: t.id, + Title: t.title, + Status: t.status || (t.completed ? "done" : "todo"), + Priority: t.priority || "medium", + "Due Date": t.dueDate || "", + "Created At": (t as any).created_at || "", + Tags: (t.tags || []).join(", ") + })); + + const worksheet = XLSX.utils.json_to_sheet(data); + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet, "Tasks"); + + XLSX.writeFile(workbook, `devtrack_tasks_${new Date().toISOString().split("T")[0]}.xlsx`); + } catch (err) { + console.error(err); + alert("Failed to export tasks to Excel."); + } finally { + setLoading(false); + } + }; + + return ( +
+ + +
+ ); +} diff --git a/src/components/dashboard/CustomizableDashboard.tsx b/src/components/dashboard/CustomizableDashboard.tsx index 6ab309446..ee4a8a95f 100644 --- a/src/components/dashboard/CustomizableDashboard.tsx +++ b/src/components/dashboard/CustomizableDashboard.tsx @@ -220,6 +220,11 @@ const SponsorAnalytics = dynamic( { ssr: false, loading: () => }, ); +const ProjectMilestones = dynamic( + () => import("@/components/ProjectMilestones"), + { ssr: false, loading: () => }, +); + const SECTION_ANCHOR_IDS: Record = { overview: "overview", activity: "streaks", @@ -255,6 +260,7 @@ const WIDGET_SPAN_CLASSES: Partial> = { "daily-note": "xl:col-span-2", "recent-activity": "xl:col-span-2", "sponsor-analytics": "xl:col-span-2", + "project-milestones": "xl:col-span-2", }; const isDashboardWidgetId = ( @@ -472,6 +478,13 @@ const renderDashboardWidget = (widgetId: DashboardWidgetId): ReactNode => { ); + case "project-milestones": + return ( + + + + ); + default: return null; } diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 3f072e201..fe42e76e4 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -40,7 +40,8 @@ export type DashboardWidgetId = | "ci-analytics" | "language-breakdown" | "friend-comparison" - | "achievement-progress"; + | "achievement-progress" + | "project-milestones"; export interface DashboardLayoutPreference { version: 1; @@ -98,6 +99,7 @@ export const DASHBOARD_WIDGET_LABELS: Record = { "language-breakdown": "Language Breakdown", "friend-comparison": "Friend Comparison", "achievement-progress": "Achievement Progress", + "project-milestones": "Project Milestones", }; export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayoutPreference = { @@ -139,6 +141,7 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayoutPreference = { "language-breakdown", "friend-comparison", "achievement-progress", + "project-milestones", ], }, hidden: [], diff --git a/src/lib/ssrf-protection.ts b/src/lib/ssrf-protection.ts index 3e537ae55..42809a9f3 100644 --- a/src/lib/ssrf-protection.ts +++ b/src/lib/ssrf-protection.ts @@ -10,7 +10,7 @@ const PRIVATE_RANGES = [ { start: 0xa9fe0000, end: 0xa9feffff }, // 169.254.0.0/16 (link-local) ]; -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); @@ -18,7 +18,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/src/types/project-milestone.ts b/src/types/project-milestone.ts new file mode 100644 index 000000000..ab4d769f5 --- /dev/null +++ b/src/types/project-milestone.ts @@ -0,0 +1,17 @@ +export interface Task { + id: string; + title: string; + completed: boolean; + status: string; + priority: string; + dueDate?: string; + tags: string[]; +} + +export interface Milestone { + id: string; + name: string; + description?: string; + dueDate: string; + taskIds: string[]; +} 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/supabase/migrations/20260724182104_add_milestones_tasks.sql b/supabase/migrations/20260724182104_add_milestones_tasks.sql new file mode 100644 index 000000000..c39b36fd8 --- /dev/null +++ b/supabase/migrations/20260724182104_add_milestones_tasks.sql @@ -0,0 +1,65 @@ +create table if not exists tasks ( + id text primary key default gen_random_uuid()::text, + user_id text not null references users(id) on delete cascade, + title text not null, + completed boolean not null default false, + milestone_id text, + created_at timestamptz default now(), + updated_at timestamptz default now() +); + +drop table if exists milestones cascade; + +create table milestones ( + id text primary key default gen_random_uuid()::text, + user_id text not null references users(id) on delete cascade, + name text not null, + description text, + due_date timestamptz, + created_at timestamptz default now(), + updated_at timestamptz default now() +); + +alter table tasks add constraint fk_tasks_milestone + foreign key (milestone_id) references milestones(id) on delete set null; + +create index if not exists tasks_user_id_idx on tasks(user_id); +create index if not exists milestones_user_id_idx on milestones(user_id); +create index if not exists tasks_milestone_id_idx on tasks(milestone_id); + +alter table tasks enable row level security; +alter table milestones enable row level security; + +create policy "tasks_select_own" + on tasks for select + using (user_id = auth.uid()::text); + +create policy "tasks_insert_own" + on tasks for insert + with check (user_id = auth.uid()::text); + +create policy "tasks_update_own" + on tasks for update + using (user_id = auth.uid()::text) + with check (user_id = auth.uid()::text); + +create policy "tasks_delete_own" + on tasks for delete + using (user_id = auth.uid()::text); + +create policy "milestones_select_own" + on milestones for select + using (user_id = auth.uid()::text); + +create policy "milestones_insert_own" + on milestones for insert + with check (user_id = auth.uid()::text); + +create policy "milestones_update_own" + on milestones for update + using (user_id = auth.uid()::text) + with check (user_id = auth.uid()::text); + +create policy "milestones_delete_own" + on milestones for delete + using (user_id = auth.uid()::text); diff --git a/supabase/migrations/20260724191502_update_tasks_schema_for_export.sql b/supabase/migrations/20260724191502_update_tasks_schema_for_export.sql new file mode 100644 index 000000000..02f70ea7a --- /dev/null +++ b/supabase/migrations/20260724191502_update_tasks_schema_for_export.sql @@ -0,0 +1,11 @@ +alter table tasks +add column if not exists status text not null default 'todo', +add column if not exists priority text not null default 'medium', +add column if not exists due_date timestamptz, +add column if not exists tags text[] not null default '{}'::text[]; + +-- Migrate existing completed tasks to status +update tasks set status = 'done' where completed = true; + +-- We can leave the `completed` column for now to avoid breaking existing code while we transition, +-- or drop it. We'll leave it for backward compatibility and just keep it in sync or phase it out. diff --git a/supabase/schema.sql b/supabase/schema.sql index 4b87d7528..d1ec1323d 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -336,6 +336,75 @@ CREATE POLICY "Users can delete own sponsor metrics" USING (auth.uid()::text = user_id); -- ------------------------------------------------------- +-- Tasks and Milestones +-- ------------------------------------------------------- +create table if not exists tasks ( + id text primary key default gen_random_uuid()::text, + user_id text not null references users(id) on delete cascade, + title text not null, + completed boolean not null default false, + status text not null default 'todo', + priority text not null default 'medium', + due_date timestamptz, + tags text[] not null default '{}'::text[], + milestone_id text, + created_at timestamptz default now(), + updated_at timestamptz default now() +); + +create table if not exists milestones ( + id text primary key default gen_random_uuid()::text, + user_id text not null references users(id) on delete cascade, + name text not null, + description text, + due_date timestamptz, + created_at timestamptz default now(), + updated_at timestamptz default now() +); + +alter table tasks add constraint fk_tasks_milestone + foreign key (milestone_id) references milestones(id) on delete set null; + +create index if not exists tasks_user_id_idx on tasks(user_id); +create index if not exists milestones_user_id_idx on milestones(user_id); +create index if not exists tasks_milestone_id_idx on tasks(milestone_id); + +alter table tasks enable row level security; +alter table milestones enable row level security; + +create policy "tasks_select_own" + on tasks for select + using (user_id = auth.uid()::text); + +create policy "tasks_insert_own" + on tasks for insert + with check (user_id = auth.uid()::text); + +create policy "tasks_update_own" + on tasks for update + using (user_id = auth.uid()::text) + with check (user_id = auth.uid()::text); + +create policy "tasks_delete_own" + on tasks for delete + using (user_id = auth.uid()::text); + +create policy "milestones_select_own" + on milestones for select + using (user_id = auth.uid()::text); + +create policy "milestones_insert_own" + on milestones for insert + with check (user_id = auth.uid()::text); + +create policy "milestones_update_own" + on milestones for update + using (user_id = auth.uid()::text) + with check (user_id = auth.uid()::text); + +create policy "milestones_delete_own" + on milestones for delete + using (user_id = auth.uid()::text); -- WakaTime Stats: caches daily WakaTime coding summaries per user -- ------------------------------------------------------- CREATE TABLE IF NOT EXISTS wakatime_stats ( @@ -355,4 +424,4 @@ ALTER TABLE wakatime_stats ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS "Users can view their own wakatime stats" ON wakatime_stats; CREATE POLICY "Users can view their own wakatime stats" ON wakatime_stats FOR SELECT - USING (auth.uid()::text = user_id); \ No newline at end of file + USING (auth.uid()::text = user_id); 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); }); }); });