diff --git a/backend/tests/uploadPhoto.test.js b/backend/tests/uploadPhoto.test.js index 0fa23380..4936b5df 100644 --- a/backend/tests/uploadPhoto.test.js +++ b/backend/tests/uploadPhoto.test.js @@ -166,8 +166,9 @@ describe("POST /api/users/upload-photo", () => { expect(res.status).toBe(200); expect(res.body.success).toBe(true); - // Filename must contain the authenticated user's ID - expect(res.body.fileUrl).toMatch(new RegExp(`profile-${TEST_USER_ID}-`)); + // Supabase storage path is scoped to the authenticated user's ID + expect(res.body.fileUrl).toContain(`${TEST_USER_ID}/`); + expect(storageUploadMock).toHaveBeenCalled(); }); it("returns 200 when a valid JWT is supplied via HttpOnly cookie", async () => { @@ -221,9 +222,9 @@ describe("POST /api/users/upload-photo", () => { expect(res.body.error).toMatch(/no file/i); }); - it("returns 413 when the uploaded file exceeds the 5MB size limit", async () => { + it("returns 413 when the uploaded file exceeds the 2MB size limit", async () => { const token = makeToken(); - const oversized = Buffer.alloc(6 * 1024 * 1024, 0xff); // 6 MB of 0xFF bytes + const oversized = Buffer.alloc(3 * 1024 * 1024, 0xff); // 3 MB of 0xFF bytes const res = await request(app) .post("/api/users/upload-photo") .set("Authorization", `Bearer ${token}`) @@ -233,7 +234,7 @@ describe("POST /api/users/upload-photo", () => { }); expect(res.status).toBe(413); - expect(res.body.error).toMatch(/5mb/i); + expect(res.body.error).toMatch(/2mb/i); }); }); diff --git a/docs/api.md b/docs/api.md index d8cda768..00c71030 100644 --- a/docs/api.md +++ b/docs/api.md @@ -130,3 +130,37 @@ Sends a browser push notification to all subscribed devices for a given `user_id ``` **Security**: Standard users may only send push notifications to themselves (IDOR prevention). Webhook callers authenticated via `WEBHOOK_SECRET` may send to any user. + +## File Upload Routes + +Authenticated multipart uploads are written to Supabase Storage. Storage paths are generated on the server from the caller's user id — clients cannot choose arbitrary object keys. + +### `POST /api/upload` + +General-purpose upload for `avatars`, `profiles`, and `resources` buckets. + +**Auth**: valid Supabase JWT (`Authorization` header or `access_token` cookie) + +**Form fields**: +- `folder`: one of `avatars`, `profiles`, `resources` +- `file`: the file to upload + +**Validation**: +- MIME type must match the destination folder allow-list +- Magic byte / content-type verification rejects spoofed uploads +- Binary content and null bytes are rejected for text resource uploads + +### `POST /api/users/upload-photo` + +Profile-photo upload into the `profiles` bucket (2MB limit). + +**Auth**: valid Supabase JWT (`Authorization` header or `access_token` cookie) + +**Form fields**: +- `profilePhoto`: JPEG, PNG, WebP, or GIF image + +**Validation**: +- 2MB size limit +- Strict image MIME allow-list +- Magic byte verification that file content matches the declared image type +- Per-user rate limit (10 uploads per hour) diff --git a/src/components/dashboard/LearningProgress.tsx b/src/components/dashboard/LearningProgress.tsx index 4391b580..65407bcf 100644 --- a/src/components/dashboard/LearningProgress.tsx +++ b/src/components/dashboard/LearningProgress.tsx @@ -1,12 +1,142 @@ +import { useEffect, useState } from "react"; import { Target } from "lucide-react"; import { motion } from "framer-motion"; +import { Link } from "react-router-dom"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "@/contexts/useAuth"; + +type Goal = { + name: string; + progress: number; + color: string; +}; + +const COLOR_PALETTE = [ + "bg-cyan-400", + "bg-blue-500", + "bg-purple-500", + "bg-emerald-500", + "bg-amber-500", +]; + +const parseLearningGoalsText = (value: string | null | undefined): string[] => { + if (!value?.trim()) return []; + return value + .split(/[\n,;|]+/) + .map((part) => part.trim()) + .filter(Boolean); +}; + +const clampProgress = (completed: number, goal: number): number => { + if (!Number.isFinite(completed) || !Number.isFinite(goal) || goal <= 0) return 0; + return Math.max(0, Math.min(100, Math.round((completed / goal) * 100))); +}; export default function LearningProgress() { - const goals = [ - { name: "Frontend Development", progress: 80, color: "bg-cyan-400" }, - { name: "Backend Development", progress: 60, color: "bg-blue-500" }, - { name: "Open Source Contributions", progress: 75, color: "bg-purple-500" }, - ]; + const { user } = useAuth(); + const [goals, setGoals] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let mounted = true; + + const fetchGoals = async () => { + if (!user) { + if (mounted) { + setGoals([]); + setLoading(false); + setError(null); + } + return; + } + + setLoading(true); + setError(null); + + try { + const [profileResult, portfolioResult] = await Promise.all([ + supabase + .from("profiles") + .select("learn_subjects, learning_goals") + .eq("id", user.id) + .maybeSingle(), + supabase + .from("portfolio_profiles") + .select("learning_progress") + .eq("profile_id", user.id) + .maybeSingle(), + ]); + + if (profileResult.error) throw profileResult.error; + if (portfolioResult.error) throw portfolioResult.error; + + const learnSubjects = Array.isArray(profileResult.data?.learn_subjects) + ? profileResult.data.learn_subjects.filter( + (item: unknown): item is string => + typeof item === "string" && item.trim().length > 0 + ) + : []; + + const textGoals = parseLearningGoalsText( + profileResult.data?.learning_goals as string | null | undefined + ); + + const progressRaw = portfolioResult.data?.learning_progress as + | { focus?: string; completed?: number; goal?: number } + | null + | undefined; + + const focus = progressRaw?.focus?.trim() || ""; + const focusProgress = clampProgress( + Number(progressRaw?.completed ?? 0), + Number(progressRaw?.goal ?? 0) + ); + + const names: string[] = []; + for (const name of [...learnSubjects, ...textGoals]) { + const trimmed = name.trim(); + if ( + trimmed && + !names.some((existing) => existing.toLowerCase() === trimmed.toLowerCase()) + ) { + names.push(trimmed); + } + } + + if ( + focus && + !names.some((existing) => existing.toLowerCase() === focus.toLowerCase()) + ) { + names.push(focus); + } + + if (!mounted) return; + + setGoals( + names.map((name, index) => ({ + name, + progress: + focus && name.toLowerCase() === focus.toLowerCase() ? focusProgress : 0, + color: COLOR_PALETTE[index % COLOR_PALETTE.length], + })) + ); + } catch (err) { + console.error("Failed to fetch learning goals:", err); + if (mounted) { + setGoals([]); + setError("Couldn't load your learning goals."); + } + } finally { + if (mounted) setLoading(false); + } + }; + + fetchGoals(); + return () => { + mounted = false; + }; + }, [user]); return (
@@ -18,29 +148,53 @@ export default function LearningProgress() {
- {goals.map((goal, index) => ( -
-
- - {goal.name} - - - {goal.progress}% - -
+ {loading && ( +

Loading goals…

+ )} -
- -
+ {!loading && error && ( +

{error}

+ )} + + {!loading && !error && goals.length === 0 && ( +
+

+ No learning goals yet. Add subjects you want to learn to get started. +

+ + Set up your goals +
- ))} + )} + + {!loading && + !error && + goals.map((goal, index) => ( +
+
+ + {goal.name} + + + {goal.progress}% + +
+ +
+ +
+
+ ))}
); -} \ No newline at end of file +}