Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions backend/tests/uploadPhoto.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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}`)
Expand All @@ -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);
});
});

Expand Down
34 changes: 34 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
201 changes: 167 additions & 34 deletions src/components/dashboard/CommunitiesWidget.tsx
Original file line number Diff line number Diff line change
@@ -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<JoinedCommunity[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<string, number>();

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 (
<div className="rounded-3xl border border-slate-800 bg-slate-900/50 p-6 flex flex-col h-full">
<div className="flex justify-between items-center mb-6">
Expand All @@ -17,43 +129,64 @@ export default function CommunitiesWidget() {
Communities
</h3>
<span className="text-sm font-medium text-slate-400 bg-slate-800 px-3 py-1 rounded-full">
{joinedCommunities.length} Joined
{loading ? "…" : `${communities.length} Joined`}
</span>
</div>

<div className="space-y-3 flex-1">
{joinedCommunities.map((community) => (
<Link
key={community.id}
to="/discover"
className="flex items-center justify-between p-3 rounded-2xl bg-slate-800/50 border border-slate-700/30 hover:border-slate-600 transition-colors group cursor-pointer"
aria-label={`Explore ${community.name} on Discover`}
>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-xl ${community.color} flex items-center justify-center text-white font-bold text-lg shadow-inner`}>
{community.name.charAt(0)}
</div>
<div>
<h4 className="text-sm font-semibold text-slate-200 group-hover:text-white transition-colors">
{community.name}
</h4>
<p className="text-xs text-slate-400">
{community.members.toLocaleString()} members
</p>
</div>
</div>
<div className="flex flex-col items-end">
<div className="flex items-center gap-1.5 mb-1">
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
<span className="text-xs text-slate-300">{community.active} active</span>
{loading && (
<p className="text-sm text-slate-400 py-6 text-center">Loading communities…</p>
)}

{!loading && error && (
<p className="text-sm text-rose-300 py-6 text-center">{error}</p>
)}

{!loading && !error && communities.length === 0 && (
<div className="py-6 text-center space-y-3">
<p className="text-sm text-slate-400">
You haven&apos;t joined any study communities yet.
</p>
<Link
to="/rooms"
className="inline-flex items-center gap-2 text-sm text-cyan-400 hover:text-cyan-300"
>
Discover communities <ExternalLink size={14} />
</Link>
</div>
)}

{!loading &&
!error &&
communities.map((community) => (
<Link
key={community.id}
to={`/rooms/${community.id}`}
className="flex items-center justify-between p-3 rounded-2xl bg-slate-800/50 border border-slate-700/30 hover:border-slate-600 transition-colors group cursor-pointer"
aria-label={`Open ${community.name}`}
>
<div className="flex items-center gap-3">
<div
className={`w-10 h-10 rounded-xl ${community.color} flex items-center justify-center text-white font-bold text-lg shadow-inner`}
>
{community.name.charAt(0)}
</div>
<div>
<h4 className="text-sm font-semibold text-slate-200 group-hover:text-white transition-colors">
{community.name}
</h4>
<p className="text-xs text-slate-400">
{community.members.toLocaleString()} member
{community.members === 1 ? "" : "s"}
</p>
</div>
</div>
</div>
</Link>
))}
</Link>
))}
</div>

<Link
to="/discover"
to="/rooms"
className="mt-4 flex items-center justify-center gap-2 text-sm text-cyan-400 hover:text-cyan-300 transition-colors py-2 rounded-xl hover:bg-cyan-400/10"
>
Explore More <ExternalLink size={14} />
Expand Down
Loading