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/CommunitiesWidget.tsx b/src/components/dashboard/CommunitiesWidget.tsx index 32069bd5..f1357eae 100644 --- a/src/components/dashboard/CommunitiesWidget.tsx +++ b/src/components/dashboard/CommunitiesWidget.tsx @@ -1,14 +1,126 @@ +import { useEffect, useState } from "react"; import { Users, ExternalLink } from "lucide-react"; import { Link } from "react-router-dom"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "@/contexts/useAuth"; -// Mock data -const joinedCommunities = [ - { id: "1", name: "Frontend Masters", members: 1240, active: 34, color: "bg-blue-500" }, - { id: "2", name: "UI/UX Designers", members: 890, active: 12, color: "bg-purple-500" }, - { id: "3", name: "React Enthusiasts", members: 2100, active: 89, color: "bg-cyan-500" }, +type JoinedCommunity = { + id: string; + name: string; + members: number; + color: string; +}; + +const COLOR_PALETTE = [ + "bg-blue-500", + "bg-purple-500", + "bg-cyan-500", + "bg-emerald-500", + "bg-amber-500", + "bg-rose-500", ]; +type ParticipantRow = { + room_id: string; + study_rooms: + | { + id: string; + topic: string | null; + } + | { + id: string; + topic: string | null; + }[] + | null; +}; + +const resolveRoom = (row: ParticipantRow) => { + if (Array.isArray(row.study_rooms)) { + return row.study_rooms[0] ?? null; + } + return row.study_rooms; +}; + export default function CommunitiesWidget() { + const { user } = useAuth(); + const [communities, setCommunities] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let mounted = true; + + const fetchCommunities = async () => { + if (!user) { + if (mounted) { + setCommunities([]); + setLoading(false); + setError(null); + } + return; + } + + setLoading(true); + setError(null); + + try { + const { data: memberships, error: membershipError } = await supabase + .from("study_room_participants") + .select("room_id, study_rooms(id, topic)") + .eq("profile_id", user.id) + .limit(12); + + if (membershipError) throw membershipError; + + const rows = (memberships ?? []) as ParticipantRow[]; + const rooms = rows + .map((row) => resolveRoom(row)) + .filter((room): room is { id: string; topic: string | null } => Boolean(room?.id)); + + const roomIds = [...new Set(rooms.map((room) => room.id))]; + const memberCounts = new Map(); + + if (roomIds.length > 0) { + const { data: participantRows, error: countError } = await supabase + .from("study_room_participants") + .select("room_id") + .in("room_id", roomIds); + + if (countError) throw countError; + + for (const row of participantRows ?? []) { + const roomId = row.room_id as string; + memberCounts.set(roomId, (memberCounts.get(roomId) ?? 0) + 1); + } + } + + if (!mounted) return; + + setCommunities( + rooms.map((room, index) => ({ + id: room.id, + name: room.topic?.trim() || "Study Room", + members: memberCounts.get(room.id) ?? 1, + color: COLOR_PALETTE[index % COLOR_PALETTE.length], + })) + ); + } catch (err) { + console.error("Failed to fetch joined communities:", err); + if (mounted) { + setCommunities([]); + setError("Couldn't load your communities."); + } + } finally { + if (mounted) setLoading(false); + } + }; + + fetchCommunities(); + return () => { + mounted = false; + }; + }, [user]); + return (
@@ -17,43 +129,64 @@ export default function CommunitiesWidget() { Communities - {joinedCommunities.length} Joined + {loading ? "…" : `${communities.length} Joined`}
- {joinedCommunities.map((community) => ( - -
-
- {community.name.charAt(0)} -
-
-

- {community.name} -

-

- {community.members.toLocaleString()} members -

-
-
-
-
- - {community.active} active + {loading && ( +

Loading communities…

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

{error}

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

+ You haven't joined any study communities yet. +

+ + Discover communities + +
+ )} + + {!loading && + !error && + communities.map((community) => ( + +
+
+ {community.name.charAt(0)} +
+
+

+ {community.name} +

+

+ {community.members.toLocaleString()} member + {community.members === 1 ? "" : "s"} +

+
-
- - ))} + + ))}
Explore More