Skip to content
Open
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
150 changes: 135 additions & 15 deletions backend/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,94 @@ export const io = new Server(server, {
export const userSockets = new Map() // userId -> Set<socketId>
export const roomTimers = new Map() // roomId -> { timeLeft, state, intervalId, type }
export const userLastActive = new Map() // userId -> timestamp
export const roomMembers = new Map() // roomId -> Map<userId, { username, isGuest, socketIds }>
export const roomHosts = new Map() // roomId -> userId

const normalizeRoomId = (roomId) =>
typeof roomId === "string" ? roomId.trim() : ""

const getRoomMembers = (roomId) => {
if (!roomMembers.has(roomId)) {
roomMembers.set(roomId, new Map())
}
return roomMembers.get(roomId)
}

const emitRoomError = (socket, event, reason) => {
socket.emit("roomError", { event, reason })
}

const canJoinRoom = (socket, roomId) => {
if (!roomId) return false
if (!socket.isGuest) return true
return socket.roomId === roomId
}

const addRoomMembership = (socket, roomId, userIdStr) => {
const members = getRoomMembers(roomId)
const existing = members.get(userIdStr)

if (existing) {
existing.socketIds.add(socket.id)
} else {
members.set(userIdStr, {
username: socket.data.username,
isGuest: socket.isGuest || false,
socketIds: new Set([socket.id]),
})
}

if (!socket.isGuest && !roomHosts.has(roomId)) {
roomHosts.set(roomId, userIdStr)
}

socket.join(roomId)
}

const isRoomMember = (roomId, userIdStr) =>
Boolean(roomMembers.get(roomId)?.has(userIdStr))

const isRoomHost = (roomId, userIdStr) => roomHosts.get(roomId) === userIdStr

const removeSocketFromRoomState = (socket, userIdStr) => {
for (const [roomId, members] of roomMembers.entries()) {
const member = members.get(userIdStr)
if (!member) continue

member.socketIds.delete(socket.id)
if (member.socketIds.size > 0) continue

members.delete(userIdStr)
if (roomHosts.get(roomId) === userIdStr) {
const nextHost = Array.from(members.entries()).find(
([, candidate]) => !candidate.isGuest,
)
if (nextHost) {
roomHosts.set(roomId, nextHost[0])
} else {
roomHosts.delete(roomId)
}
}

if (members.size === 0) {
roomMembers.delete(roomId)
roomHosts.delete(roomId)
}
}
}

const removeRoomMember = (roomId, userIdStr) => {
const members = roomMembers.get(roomId)
if (!members?.has(userIdStr)) return false

members.delete(userIdStr)
if (members.size === 0) {
roomMembers.delete(roomId)
roomHosts.delete(roomId)
}

return true
}

// Socket rate limiting: max 20 concurrent connections per IP
const socketConnectionCounts = new Map()
Expand Down Expand Up @@ -195,7 +283,7 @@ io.on("connection", async (socket) => {

// If guest, auto-join their specific room
if (socket.isGuest && socket.roomId) {
socket.join(socket.roomId)
addRoomMembership(socket, socket.roomId, userIdStr)
socket.to(socket.roomId).emit("userJoinedRoom", {
userId: userIdStr,
username: socket.data.username,
Expand Down Expand Up @@ -244,14 +332,20 @@ io.on("connection", async (socket) => {

// Room Handlers
socket.on("joinRoom", (roomId) => {
socket.join(roomId)
console.log(`User ${userIdStr} joined room ${roomId}`)
socket.to(roomId).emit("userJoinedRoom", {
const safeRoomId = normalizeRoomId(roomId)
if (!canJoinRoom(socket, safeRoomId)) {
emitRoomError(socket, "joinRoom", "You are not allowed to join this room.")
return
}

addRoomMembership(socket, safeRoomId, userIdStr)
console.log(`User ${userIdStr} joined room ${safeRoomId}`)
socket.to(safeRoomId).emit("userJoinedRoom", {
userId: userIdStr,
username: socket.data.username,
isGuest: socket.isGuest || false,
})
const timer = roomTimers.get(roomId)
const timer = roomTimers.get(safeRoomId)
if (timer) {
socket.emit("pomodoroTick", { timeLeft: timer.timeLeft, state: timer.state, type: timer.type })
}
Expand Down Expand Up @@ -287,35 +381,59 @@ io.on("connection", async (socket) => {
})

socket.on("roomMessage", (data) => {
const roomId = normalizeRoomId(data?.roomId)
if (!isRoomMember(roomId, userIdStr)) {
emitRoomError(socket, "roomMessage", "Join the room before sending messages.")
return
}

// Ephemeral room message (not saved to DB)
io.to(data.roomId).emit("newRoomMessage", {
roomId: data.roomId,
io.to(roomId).emit("newRoomMessage", {
roomId,
senderId: userIdStr,
senderName: socket.data.username,
isGuest: socket.isGuest || false,
text: data.text,
text: typeof data?.text === "string" ? data.text : "",
createdAt: new Date().toISOString(),
})
})

socket.on("removeGuest", (data) => {
// Only host should call this. Data = { roomId, guestId }
// In a real app, verify this socket is the host of the room
const targetSockets = userSockets.get(data.guestId)
const roomId = normalizeRoomId(data?.roomId)
const guestId = typeof data?.guestId === "string" ? data.guestId : ""

if (!isRoomHost(roomId, userIdStr)) {
emitRoomError(socket, "removeGuest", "Only the room host can remove guests.")
return
}

const target = roomMembers.get(roomId)?.get(guestId)
if (!target?.isGuest) {
emitRoomError(socket, "removeGuest", "Guest is not in this room.")
return
}

removeRoomMember(roomId, guestId)
const targetSockets = userSockets.get(guestId)
if (targetSockets) {
targetSockets.forEach((socketId) => {
const targetSocket = io.sockets.sockets.get(socketId)
if (targetSocket) {
targetSocket.leave(data.roomId)
targetSocket.emit("removedFromRoom", { roomId: data.roomId })
targetSocket.leave(roomId)
targetSocket.emit("removedFromRoom", { roomId })
}
})
}
})

socket.on("pomodoroAction", (data) => {
if (socket.isGuest) return; // Only host can control timer
const { roomId, action, type } = data;
const roomId = normalizeRoomId(data?.roomId)
if (!isRoomHost(roomId, userIdStr)) {
emitRoomError(socket, "pomodoroAction", "Only the room host can control the timer.")
return
}

const { action, type } = data;
let timer = roomTimers.get(roomId);

if (action === "start") {
Expand Down Expand Up @@ -370,6 +488,7 @@ io.on("connection", async (socket) => {
userLastActive.set(userIdStr, Date.now())
console.log(`User disconnected: ${userIdStr} (socket: ${socket.id})`)
}
removeSocketFromRoomState(socket, userIdStr)
io.emit("getOnlineUsers", Array.from(userSockets.keys()))
})
})
Expand Down Expand Up @@ -437,3 +556,4 @@ if (isVercel()) {

// Export for potential use in integration tests (e.g., supertest)
export default app
export { server }
153 changes: 153 additions & 0 deletions backend/tests/roomSocketAuth.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
import { io as Client } from "socket.io-client"
import jwt from "jsonwebtoken"
import mongoose from "mongoose"
import {
server,
roomHosts,
roomMembers,
roomTimers,
userSockets,
} from "../server.js"

const waitFor = (socket, event) =>
new Promise((resolve) => {
socket.once(event, resolve)
})

const waitForTick = () => new Promise((resolve) => setTimeout(resolve, 25))

const closeSocket = (socket) =>
new Promise((resolve) => {
if (!socket?.connected) {
socket?.close()
resolve()
return
}
socket.once("disconnect", resolve)
socket.close()
})

const connectSocket = async (url, payload) => {
const token = jwt.sign(payload, process.env.JWT_SECRET)
const socket = Client(url, {
auth: { token },
forceNew: true,
reconnection: false,
transports: ["websocket"],
})
await waitFor(socket, "connect")
return socket
}

describe("room socket authorization", () => {
let baseUrl
let hostSocket
let guestSocket
let outsiderSocket

beforeAll(async () => {
await new Promise((resolve) => {
server.listen(0, resolve)
})
const address = server.address()
baseUrl = `http://127.0.0.1:${address.port}`
})

afterAll(async () => {
await Promise.all(
[hostSocket, guestSocket, outsiderSocket].filter(Boolean).map(closeSocket),
)
await new Promise((resolve) => server.close(resolve))
})

beforeEach(async () => {
await Promise.all(
[hostSocket, guestSocket, outsiderSocket].filter(Boolean).map(closeSocket),
)
hostSocket = null
guestSocket = null
outsiderSocket = null
roomHosts.clear()
roomMembers.clear()
roomTimers.clear()
userSockets.clear()
})

it("rejects guest attempts to join arbitrary rooms", async () => {
guestSocket = await connectSocket(baseUrl, {
id: "guest-allowed",
name: "Guest Allowed",
isGuest: true,
roomId: "allowed-room",
})

const errorPromise = waitFor(guestSocket, "roomError")
guestSocket.emit("joinRoom", "other-room")

await expect(errorPromise).resolves.toEqual({
event: "joinRoom",
reason: "You are not allowed to join this room.",
})
expect(roomMembers.get("allowed-room")?.has("guest-allowed")).toBe(true)
expect(roomMembers.get("other-room")?.has("guest-allowed")).toBeFalsy()
})

it("rejects room messages from sockets that are not room members", async () => {
const hostId = new mongoose.Types.ObjectId().toString()
const outsiderId = new mongoose.Types.ObjectId().toString()
hostSocket = await connectSocket(baseUrl, { id: hostId })
outsiderSocket = await connectSocket(baseUrl, { id: outsiderId })

hostSocket.emit("joinRoom", "secure-room")
await waitForTick()

const errorPromise = waitFor(outsiderSocket, "roomError")
outsiderSocket.emit("roomMessage", {
roomId: "secure-room",
text: "I should not be delivered",
})

await expect(errorPromise).resolves.toEqual({
event: "roomMessage",
reason: "Join the room before sending messages.",
})
})

it("allows only the room host to remove guest members", async () => {
const hostId = new mongoose.Types.ObjectId().toString()
const outsiderId = new mongoose.Types.ObjectId().toString()
hostSocket = await connectSocket(baseUrl, { id: hostId })
outsiderSocket = await connectSocket(baseUrl, { id: outsiderId })
guestSocket = await connectSocket(baseUrl, {
id: "guest-removable",
name: "Guest Removable",
isGuest: true,
roomId: "host-room",
})

hostSocket.emit("joinRoom", "host-room")
await waitForTick()

const deniedPromise = waitFor(outsiderSocket, "roomError")
outsiderSocket.emit("removeGuest", {
roomId: "host-room",
guestId: "guest-removable",
})

await expect(deniedPromise).resolves.toEqual({
event: "removeGuest",
reason: "Only the room host can remove guests.",
})
expect(roomMembers.get("host-room")?.has("guest-removable")).toBe(true)

const removedPromise = waitFor(guestSocket, "removedFromRoom")
hostSocket.emit("removeGuest", {
roomId: "host-room",
guestId: "guest-removable",
})

await expect(removedPromise).resolves.toEqual({ roomId: "host-room" })
expect(roomMembers.get("host-room")?.has("guest-removable")).toBeFalsy()
})
})
Loading